serverless-ircd 0.6.0 → 0.8.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 (350) hide show
  1. package/.github/workflows/ci.yml +9 -4
  2. package/.gitmodules +4 -1
  3. package/CHANGELOG.md +480 -22
  4. package/README.md +244 -57
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  11. package/apps/cf-worker/src/worker.ts +89 -9
  12. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  13. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  14. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  15. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  16. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  17. package/apps/cf-worker/wrangler.test.toml +23 -0
  18. package/apps/cf-worker/wrangler.toml +56 -0
  19. package/apps/local-cli/package.json +1 -1
  20. package/apps/local-cli/src/config-loader.ts +8 -0
  21. package/apps/local-cli/src/main.ts +16 -0
  22. package/apps/local-cli/src/server.ts +1 -0
  23. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  24. package/apps/web/landing/index.html +215 -0
  25. package/apps/web/package.json +28 -0
  26. package/apps/web/scripts/build.mjs +132 -0
  27. package/apps/web/src/build-env.ts +43 -0
  28. package/apps/web/src/config-schema.ts +138 -0
  29. package/apps/web/static/config.json +28 -0
  30. package/apps/web/static/config.prod.json +28 -0
  31. package/apps/web/static/config.staging.json +28 -0
  32. package/apps/web/tests/build-env.test.ts +63 -0
  33. package/apps/web/tests/build-smoke.test.ts +103 -0
  34. package/apps/web/tests/config-schema.test.ts +432 -0
  35. package/apps/web/tests/workspace.test.ts +12 -0
  36. package/apps/web/tsconfig.json +10 -0
  37. package/apps/web/upstream/.browserslistrc +3 -0
  38. package/apps/web/upstream/.editorconfig +9 -0
  39. package/apps/web/upstream/.eslintignore +3 -0
  40. package/apps/web/upstream/.eslintrc.js +90 -0
  41. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  42. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  43. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  44. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  45. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  46. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  47. package/apps/web/upstream/.prettierrc.js +9 -0
  48. package/apps/web/upstream/.stylelintignore +5 -0
  49. package/apps/web/upstream/.stylelintrc.js +21 -0
  50. package/apps/web/upstream/LICENSE +201 -0
  51. package/apps/web/upstream/README.md +76 -0
  52. package/apps/web/upstream/babel.config.js +25 -0
  53. package/apps/web/upstream/crowdin.yml +3 -0
  54. package/apps/web/upstream/index.html +35 -0
  55. package/apps/web/upstream/jest.config.js +7 -0
  56. package/apps/web/upstream/jsdoc.json +22 -0
  57. package/apps/web/upstream/package.json +80 -0
  58. package/apps/web/upstream/scripts/pre-commit +43 -0
  59. package/apps/web/upstream/src/components/App.vue +516 -0
  60. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  61. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  62. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  63. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  64. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  65. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  66. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  67. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  68. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  69. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  70. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  71. package/apps/web/upstream/src/components/Container.vue +401 -0
  72. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  73. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  74. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  75. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  76. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  77. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  78. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  79. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  80. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  81. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  82. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  83. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  84. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  85. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  86. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  87. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  88. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  89. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  90. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  91. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  92. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  93. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  94. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  95. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  96. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  97. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  98. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  99. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  100. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  101. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  102. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  103. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  104. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  105. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  106. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  107. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  108. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  109. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  110. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  111. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  112. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  113. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  114. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  115. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  116. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  117. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  118. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  119. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  120. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  121. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  122. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  123. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  124. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  125. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  126. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  127. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  128. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  129. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  130. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  131. package/apps/web/upstream/src/libs/IPC.js +51 -0
  132. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  133. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  134. package/apps/web/upstream/src/libs/Logger.js +71 -0
  135. package/apps/web/upstream/src/libs/Message.js +169 -0
  136. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  137. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  138. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  139. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  140. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  141. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  142. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  143. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  144. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  145. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  146. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  147. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  148. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  149. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  150. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  151. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  152. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  153. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  154. package/apps/web/upstream/src/libs/state/common.js +26 -0
  155. package/apps/web/upstream/src/libs/state.js +961 -0
  156. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  157. package/apps/web/upstream/src/main.js +566 -0
  158. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  159. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  160. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  161. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  162. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  163. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  164. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  165. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  166. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  167. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  168. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  169. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  170. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  171. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  172. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  173. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  174. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  175. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  176. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  177. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  186. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  191. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  199. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  200. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  201. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  202. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  203. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  204. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  205. package/apps/web/upstream/src/res/localesList.json +27 -0
  206. package/apps/web/upstream/src/res/logo.png +0 -0
  207. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  208. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  209. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  210. package/apps/web/upstream/static/config.json +28 -0
  211. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  212. package/apps/web/upstream/static/favicon.png +0 -0
  213. package/apps/web/upstream/static/highlight.mp3 +0 -0
  214. package/apps/web/upstream/static/highlight.ogg +0 -0
  215. package/apps/web/upstream/static/locales/.gitignore +2 -0
  216. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  217. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  218. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  219. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  220. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  221. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  222. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  223. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  224. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  225. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  226. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  227. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  228. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  229. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  230. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  231. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  232. package/apps/web/upstream/vue.config.js +150 -0
  233. package/apps/web/upstream/yarn.lock +9767 -0
  234. package/apps/web/vitest.config.ts +13 -0
  235. package/biome.json +1 -0
  236. package/docs/AWS-Deployment.md +21 -8
  237. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  238. package/docs/Home.md +1 -0
  239. package/docs/PlanExtensions.md +113 -3
  240. package/docs/Release-Process.md +23 -13
  241. package/docs/Services.md +546 -0
  242. package/docs/WebClientGuide.md +536 -0
  243. package/package.json +2 -2
  244. package/packages/aws-adapter/package.json +1 -1
  245. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  246. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  247. package/packages/aws-adapter/src/config-loader.ts +11 -0
  248. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  249. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  250. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  251. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  252. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  253. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  254. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  255. package/packages/aws-adapter/src/index.ts +4 -0
  256. package/packages/aws-adapter/src/stats.ts +6 -1
  257. package/packages/aws-adapter/src/tables.ts +34 -4
  258. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  259. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  260. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  261. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  262. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  263. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  264. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  265. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  266. package/packages/cf-adapter/package.json +1 -1
  267. package/packages/cf-adapter/src/config-loader.ts +11 -0
  268. package/packages/cf-adapter/src/connection-do.ts +112 -2
  269. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  270. package/packages/cf-adapter/src/env.ts +8 -0
  271. package/packages/cf-adapter/src/index.ts +5 -0
  272. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  273. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  274. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  275. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  276. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  277. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  278. package/packages/in-memory-runtime/package.json +1 -1
  279. package/packages/irc-core/package.json +1 -1
  280. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  281. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  282. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  283. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  284. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  285. package/packages/irc-core/src/commands/index.ts +12 -0
  286. package/packages/irc-core/src/commands/join.ts +164 -8
  287. package/packages/irc-core/src/commands/markread.ts +202 -0
  288. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  289. package/packages/irc-core/src/commands/mode.ts +96 -4
  290. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  291. package/packages/irc-core/src/commands/oper.ts +18 -1
  292. package/packages/irc-core/src/commands/operserv.ts +346 -0
  293. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  294. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  295. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  296. package/packages/irc-core/src/commands/registration.ts +61 -6
  297. package/packages/irc-core/src/commands/sasl.ts +18 -49
  298. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  299. package/packages/irc-core/src/commands/topic.ts +37 -0
  300. package/packages/irc-core/src/config.ts +36 -5
  301. package/packages/irc-core/src/effects.ts +56 -1
  302. package/packages/irc-core/src/ports.ts +1653 -84
  303. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  304. package/packages/irc-core/src/state/channel.ts +21 -1
  305. package/packages/irc-core/src/state/connection.ts +25 -1
  306. package/packages/irc-core/src/types.ts +48 -12
  307. package/packages/irc-core/tests/batch.test.ts +15 -0
  308. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  309. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  310. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  311. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  312. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  313. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  314. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  315. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  316. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  317. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  318. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  319. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  320. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  321. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  322. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  323. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  324. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  325. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  326. package/packages/irc-core/tests/config.test.ts +49 -5
  327. package/packages/irc-core/tests/effects.test.ts +19 -0
  328. package/packages/irc-core/tests/message-store.test.ts +63 -0
  329. package/packages/irc-core/tests/numerics.test.ts +13 -0
  330. package/packages/irc-core/tests/parser.test.ts +109 -0
  331. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  332. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  333. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  334. package/packages/irc-server/package.json +1 -1
  335. package/packages/irc-server/src/actor.ts +71 -16
  336. package/packages/irc-server/src/dispatch.ts +94 -7
  337. package/packages/irc-server/src/routing.ts +19 -0
  338. package/packages/irc-server/tests/actor.test.ts +623 -12
  339. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  340. package/packages/irc-server/tests/routing.test.ts +6 -0
  341. package/packages/irc-test-support/package.json +1 -1
  342. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  343. package/packages/irc-test-support/src/index.ts +1 -0
  344. package/packages/irc-test-support/src/scenarios.ts +24 -2
  345. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  346. package/tools/ci-hardening/package.json +1 -1
  347. package/tools/load-test/package.json +1 -1
  348. package/tools/tcp-ws-forwarder/package.json +1 -1
  349. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  350. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,342 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <script>
6
+ var MutationTestElements=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),n=globalThis,r=n.ShadowRoot&&(n.ShadyCSS===void 0||n.ShadyCSS.nativeShadow)&&`adoptedStyleSheets`in Document.prototype&&`replace`in CSSStyleSheet.prototype,i=Symbol(),a=new WeakMap,o=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(r&&e===void 0){let n=t!==void 0&&t.length===1;n&&(e=a.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&a.set(t,e))}return e}toString(){return this.cssText}},s=e=>new o(typeof e==`string`?e:e+``,void 0,i),c=(e,t)=>{if(r)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let r of t){let t=document.createElement(`style`),i=n.litNonce;i!==void 0&&t.setAttribute(`nonce`,i),t.textContent=r.cssText,e.appendChild(t)}},l=r?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t=``;for(let n of e.cssRules)t+=n.cssText;return s(t)})(e):e,{is:u,defineProperty:d,getOwnPropertyDescriptor:f,getOwnPropertyNames:p,getOwnPropertySymbols:m,getPrototypeOf:h}=Object,g=globalThis,_=g.trustedTypes,v=_?_.emptyScript:``,y=g.reactiveElementPolyfillSupport,b=(e,t)=>e,x={toAttribute(e,t){switch(t){case Boolean:e=e?v:null;break;case Object:case Array:e=e==null?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=e!==null;break;case Number:n=e===null?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch{n=null}}return n}},S=(e,t)=>!u(e,t),C={attribute:!0,type:String,converter:x,reflect:!1,useDefault:!1,hasChanged:S};Symbol.metadata??=Symbol(`metadata`),g.litPropertyMetadata??=new WeakMap;var w=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=C){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let n=Symbol(),r=this.getPropertyDescriptor(e,n,t);r!==void 0&&d(this.prototype,e,r)}}static getPropertyDescriptor(e,t,n){let{get:r,set:i}=f(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:r,set(t){let a=r?.call(this);i?.call(this,t),this.requestUpdate(e,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??C}static _$Ei(){if(this.hasOwnProperty(b(`elementProperties`)))return;let e=h(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(b(`finalized`)))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(b(`properties`))){let e=this.properties,t=[...p(e),...m(e)];for(let n of t)this.createProperty(n,e[n])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let n=this._$Eu(e,t);n!==void 0&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let n=new Set(e.flat(1/0).reverse());for(let e of n)t.unshift(l(e))}else e!==void 0&&t.push(l(e));return t}static _$Eu(e,t){let n=t.attribute;return!1===n?void 0:typeof n==`string`?n:typeof e==`string`?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return c(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){let n=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,n);if(r!==void 0&&!0===n.reflect){let i=(n.converter?.toAttribute===void 0?x:n.converter).toAttribute(t,n.type);this._$Em=e,i==null?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(e,t){let n=this.constructor,r=n._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let e=n.getPropertyOptions(r),i=typeof e.converter==`function`?{fromAttribute:e.converter}:e.converter?.fromAttribute===void 0?x:e.converter;this._$Em=r;let a=i.fromAttribute(t,e.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(e,t,n,r=!1,i){if(e!==void 0){let a=this.constructor;if(!1===r&&(i=this[e]),n??=a.getPropertyOptions(e),!((n.hasChanged??S)(i,t)||n.useDefault&&n.reflect&&i===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,a??t??this[e]),!0!==i||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===r&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}let e=this.constructor.elementProperties;if(e.size>0)for(let[t,n]of e){let{wrapped:e}=n,r=this[t];!0!==e||this._$AL.has(t)||r===void 0||this.C(t,void 0,n,r)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};w.elementStyles=[],w.shadowRootOptions={mode:`open`},w[b(`elementProperties`)]=new Map,w[b(`finalized`)]=new Map,y?.({ReactiveElement:w}),(g.reactiveElementVersions??=[]).push(`2.1.2`);var T=globalThis,E=e=>e,D=T.trustedTypes,ee=D?D.createPolicy(`lit-html`,{createHTML:e=>e}):void 0,O=`$lit$`,k=`lit$${Math.random().toFixed(9).slice(2)}$`,te=`?`+k,ne=`<${te}>`,A=document,re=()=>A.createComment(``),ie=e=>e===null||typeof e!=`object`&&typeof e!=`function`,ae=Array.isArray,oe=e=>ae(e)||typeof e?.[Symbol.iterator]==`function`,se=`[
7
+ \f\r]`,ce=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,le=/-->/g,ue=/>/g,de=RegExp(`>|${se}(?:([^\\s"'>=/]+)(${se}*=${se}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,`g`),fe=/'/g,pe=/"/g,me=/^(?:script|style|textarea|title)$/i,he=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),j=he(1),M=he(2),N=Symbol.for(`lit-noChange`),P=Symbol.for(`lit-nothing`),ge=new WeakMap,_e=A.createTreeWalker(A,129);function ve(e,t){if(!ae(e)||!e.hasOwnProperty(`raw`))throw Error(`invalid template strings array`);return ee===void 0?t:ee.createHTML(t)}var ye=(e,t)=>{let n=e.length-1,r=[],i,a=t===2?`<svg>`:t===3?`<math>`:``,o=ce;for(let t=0;t<n;t++){let n=e[t],s,c,l=-1,u=0;for(;u<n.length&&(o.lastIndex=u,c=o.exec(n),c!==null);)u=o.lastIndex,o===ce?c[1]===`!--`?o=le:c[1]===void 0?c[2]===void 0?c[3]!==void 0&&(o=de):(me.test(c[2])&&(i=RegExp(`</`+c[2],`g`)),o=de):o=ue:o===de?c[0]===`>`?(o=i??ce,l=-1):c[1]===void 0?l=-2:(l=o.lastIndex-c[2].length,s=c[1],o=c[3]===void 0?de:c[3]===`"`?pe:fe):o===pe||o===fe?o=de:o===le||o===ue?o=ce:(o=de,i=void 0);let d=o===de&&e[t+1].startsWith(`/>`)?` `:``;a+=o===ce?n+ne:l>=0?(r.push(s),n.slice(0,l)+O+n.slice(l)+k+d):n+k+(l===-2?t:d)}return[ve(e,a+(e[n]||`<?>`)+(t===2?`</svg>`:t===3?`</math>`:``)),r]},be=class e{constructor({strings:t,_$litType$:n},r){let i;this.parts=[];let a=0,o=0,s=t.length-1,c=this.parts,[l,u]=ye(t,n);if(this.el=e.createElement(l,r),_e.currentNode=this.el.content,n===2||n===3){let e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;(i=_e.nextNode())!==null&&c.length<s;){if(i.nodeType===1){if(i.hasAttributes())for(let e of i.getAttributeNames())if(e.endsWith(O)){let t=u[o++],n=i.getAttribute(e).split(k),r=/([.?@])?(.*)/.exec(t);c.push({type:1,index:a,name:r[2],strings:n,ctor:r[1]===`.`?Te:r[1]===`?`?Ee:r[1]===`@`?De:we}),i.removeAttribute(e)}else e.startsWith(k)&&(c.push({type:6,index:a}),i.removeAttribute(e));if(me.test(i.tagName)){let e=i.textContent.split(k),t=e.length-1;if(t>0){i.textContent=D?D.emptyScript:``;for(let n=0;n<t;n++)i.append(e[n],re()),_e.nextNode(),c.push({type:2,index:++a});i.append(e[t],re())}}}else if(i.nodeType===8)if(i.data===te)c.push({type:2,index:a});else{let e=-1;for(;(e=i.data.indexOf(k,e+1))!==-1;)c.push({type:7,index:a}),e+=k.length-1}a++}}static createElement(e,t){let n=A.createElement(`template`);return n.innerHTML=e,n}};function xe(e,t,n=e,r){if(t===N)return t;let i=r===void 0?n._$Cl:n._$Co?.[r],a=ie(t)?void 0:t._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),a===void 0?i=void 0:(i=new a(e),i._$AT(e,n,r)),r===void 0?n._$Cl=i:(n._$Co??=[])[r]=i),i!==void 0&&(t=xe(e,i._$AS(e,t.values),i,r)),t}var Se=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:n}=this._$AD,r=(e?.creationScope??A).importNode(t,!0);_e.currentNode=r;let i=_e.nextNode(),a=0,o=0,s=n[0];for(;s!==void 0;){if(a===s.index){let t;s.type===2?t=new Ce(i,i.nextSibling,this,e):s.type===1?t=new s.ctor(i,s.name,s.strings,this,e):s.type===6&&(t=new Oe(i,this,e)),this._$AV.push(t),s=n[++o]}a!==s?.index&&(i=_e.nextNode(),a++)}return _e.currentNode=A,r}p(e){let t=0;for(let n of this._$AV)n!==void 0&&(n.strings===void 0?n._$AI(e[t]):(n._$AI(e,n,t),t+=n.strings.length-2)),t++}},Ce=class e{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,n,r){this.type=2,this._$AH=P,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=xe(this,e,t),ie(e)?e===P||e==null||e===``?(this._$AH!==P&&this._$AR(),this._$AH=P):e!==this._$AH&&e!==N&&this._(e):e._$litType$===void 0?e.nodeType===void 0?oe(e)?this.k(e):this._(e):this.T(e):this.$(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==P&&ie(this._$AH)?this._$AA.nextSibling.data=e:this.T(A.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:n}=e,r=typeof n==`number`?this._$AC(e):(n.el===void 0&&(n.el=be.createElement(ve(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(t);else{let e=new Se(r,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=ge.get(e.strings);return t===void 0&&ge.set(e.strings,t=new be(e)),t}k(t){ae(this._$AH)||(this._$AH=[],this._$AR());let n=this._$AH,r,i=0;for(let a of t)i===n.length?n.push(r=new e(this.O(re()),this.O(re()),this,this.options)):r=n[i],r._$AI(a),i++;i<n.length&&(this._$AR(r&&r._$AB.nextSibling,i),n.length=i)}_$AR(e=this._$AA.nextSibling,t){for(this._$AP?.(!1,!0,t);e!==this._$AB;){let t=E(e).nextSibling;E(e).remove(),e=t}}setConnected(e){this._$AM===void 0&&(this._$Cv=e,this._$AP?.(e))}},we=class{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(e,t,n,r,i){this.type=1,this._$AH=P,this._$AN=void 0,this.element=e,this.name=t,this._$AM=r,this.options=i,n.length>2||n[0]!==``||n[1]!==``?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=P}_$AI(e,t=this,n,r){let i=this.strings,a=!1;if(i===void 0)e=xe(this,e,t,0),a=!ie(e)||e!==this._$AH&&e!==N,a&&(this._$AH=e);else{let r=e,o,s;for(e=i[0],o=0;o<i.length-1;o++)s=xe(this,r[n+o],t,o),s===N&&(s=this._$AH[o]),a||=!ie(s)||s!==this._$AH[o],s===P?e=P:e!==P&&(e+=(s??``)+i[o+1]),this._$AH[o]=s}a&&!r&&this.j(e)}j(e){e===P?this.element.removeAttribute(this.name):this.element.setAttribute(this.name,e??``)}},Te=class extends we{constructor(){super(...arguments),this.type=3}j(e){this.element[this.name]=e===P?void 0:e}},Ee=class extends we{constructor(){super(...arguments),this.type=4}j(e){this.element.toggleAttribute(this.name,!!e&&e!==P)}},De=class extends we{constructor(e,t,n,r,i){super(e,t,n,r,i),this.type=5}_$AI(e,t=this){if((e=xe(this,e,t,0)??P)===N)return;let n=this._$AH,r=e===P&&n!==P||e.capture!==n.capture||e.once!==n.once||e.passive!==n.passive,i=e!==P&&(n===P||r);r&&this.element.removeEventListener(this.name,this,n),i&&this.element.addEventListener(this.name,this,e),this._$AH=e}handleEvent(e){typeof this._$AH==`function`?this._$AH.call(this.options?.host??this.element,e):this._$AH.handleEvent(e)}},Oe=class{constructor(e,t,n){this.element=e,this.type=6,this._$AN=void 0,this._$AM=t,this.options=n}get _$AU(){return this._$AM._$AU}_$AI(e){xe(this,e)}},ke={M:O,P:k,A:te,C:1,L:ye,R:Se,D:oe,V:xe,I:Ce,H:we,N:Ee,U:De,B:Te,F:Oe},Ae=T.litHtmlPolyfillSupport;Ae?.(be,Ce),(T.litHtmlVersions??=[]).push(`3.3.2`);var je=(e,t,n)=>{let r=n?.renderBefore??t,i=r._$litPart$;if(i===void 0){let e=n?.renderBefore??null;r._$litPart$=i=new Ce(t.insertBefore(re(),e),e,void 0,n??{})}return i._$AI(e),i},Me=globalThis,Ne=class extends w{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=je(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return N}};Ne._$litElement$=!0,Ne.finalized=!0,Me.litElementHydrateSupport?.({LitElement:Ne});var Pe=Me.litElementPolyfillSupport;Pe?.({LitElement:Ne}),(Me.litElementVersions??=[]).push(`4.2.2`);var F=e=>(t,n)=>{n===void 0?customElements.define(e,t):n.addInitializer(()=>{customElements.define(e,t)})},Fe={attribute:!0,type:String,converter:x,reflect:!1,hasChanged:S},Ie=(e=Fe,t,n)=>{let{kind:r,metadata:i}=n,a=globalThis.litPropertyMetadata.get(i);if(a===void 0&&globalThis.litPropertyMetadata.set(i,a=new Map),r===`setter`&&((e=Object.create(e)).wrapped=!0),a.set(n.name,e),r===`accessor`){let{name:r}=n;return{set(n){let i=t.get.call(this);t.set.call(this,n),this.requestUpdate(r,i,e,!0,n)},init(t){return t!==void 0&&this.C(r,void 0,e,t),t}}}if(r===`setter`){let{name:r}=n;return function(n){let i=this[r];t.call(this,n),this.requestUpdate(r,i,e,!0,n)}}throw Error(`Unsupported decorator location: `+r)};function I(e){return(t,n)=>typeof n==`object`?Ie(e,t,n):((e,t,n)=>{let r=t.hasOwnProperty(n);return t.constructor.createProperty(n,e),r?Object.getOwnPropertyDescriptor(t,n):void 0})(e,t,n)}function L(e){return I({...e,state:!0,attribute:!1})}var Le=(e,t,n)=>(n.configurable=!0,n.enumerable=!0,Reflect.decorate&&typeof t!=`object`&&Object.defineProperty(e,t,n),n);function Re(e,t){return(n,r,i)=>{let a=t=>t.renderRoot?.querySelector(e)??null;if(t){let{get:e,set:t}=typeof r==`object`?n:i??(()=>{let e=Symbol();return{get(){return this[e]},set(t){this[e]=t}}})();return Le(n,r,{get(){let n=e.call(this);return n===void 0&&(n=a(this),(n!==null||this.hasUpdated)&&t.call(this,n)),n}})}return Le(n,r,{get(){return a(this)}})}}var ze=e=>e??P;function R(e,t,n){return e?t(e):n?.(e)}var Be=`/`;function Ve(e,t,n){let r=Object.keys(e),i=Ue(r);return r.reduce((r,a)=>{let o=He(a.startsWith(t)?a.substr(t.length):a);return r[He(a.substr(i.length))]=n(e[a],o),r},Object.create(null))}function He(e){return e.split(/\/|\\/).filter(Boolean).join(`/`)}function Ue(e){let t=e.map(e=>e.split(/\/|\\/).slice(0,-1));if(e.length)return t.reduce(n).join(Be);return``;function n(e,t){for(let n=0;n<e.length;n++)if(e[n]!==t[n])return e.splice(0,n);return e}}function We(e,t){let n=e=>e.file?`1${e.name}`:`0${e.name}`;return n(e).localeCompare(n(t))}function Ge(e){return e!=null}var Ke={maxAsciiCharacter:127,lineFeed:10,carriageReturn:13,lineSeparator:8232,paragraphSeparator:8233};function qe(e){return e===Ke.lineFeed||e===Ke.carriageReturn||e===Ke.lineSeparator||e===Ke.paragraphSeparator}function Je(e){let t=[],n=0,r=0;function i(e){t.push(r),r=e}for(i(0);n<e.length;){let t=e.charCodeAt(n);switch(n++,t){case Ke.carriageReturn:e.charCodeAt(n)===Ke.lineFeed&&n++,i(n);break;case Ke.lineFeed:i(n);break;default:t>Ke.maxAsciiCharacter&&qe(t)&&i(n);break}}return t.push(r),t}function Ye(e,t){return Object.groupBy?Object.groupBy(e,t):e.reduce((e,n)=>{let r=t(n);return e[r]??=[],e[r].push(n),e},Object.create(null))}function Xe(e){if(e===void 0)throw Error(`mutant.sourceFile was not defined`)}var Ze=class{coveredBy;description;duration;id;killedBy;location;mutatorName;replacement;static;status;statusReason;testsCompleted;get coveredByTests(){if(this.#e.size)return Array.from(this.#e.values())}set coveredByTests(e){this.#e=new Map(e.map(e=>[e.id,e]))}get killedByTests(){if(this.#t.size)return Array.from(this.#t.values())}set killedByTests(e){this.#t=new Map(e.map(e=>[e.id,e]))}#e=new Map;#t=new Map;constructor(e){this.coveredBy=e.coveredBy,this.description=e.description,this.duration=e.duration,this.id=e.id,this.killedBy=e.killedBy,this.location=e.location,this.mutatorName=e.mutatorName,this.replacement=e.replacement,this.static=e.static,this.status=e.status,this.statusReason=e.statusReason,this.testsCompleted=e.testsCompleted}addCoveredBy(e){this.#e.set(e.id,e)}addKilledBy(e){this.#t.set(e.id,e)}getMutatedLines(){return Xe(this.sourceFile),this.sourceFile.getMutationLines(this)}getOriginalLines(){return Xe(this.sourceFile),this.sourceFile.getLines(this.location)}get fileName(){return Xe(this.sourceFile),this.sourceFile.name}update(){this.sourceFile?.result?.file&&this.sourceFile.result.updateAllMetrics()}};function Qe(e){if(e===void 0)throw Error(`sourceFile.source is undefined`)}var $e=class{#e;getLineMap(){return Qe(this.source),this.#e??=Je(this.source)}getLines(e){Qe(this.source);let t=this.getLineMap();return this.source.substring(t[e.start.line],t[(e.end??e.start).line+1])}},et=class extends $e{language;source;mutants;result;name;constructor(e,t){super(),this.language=e.language,this.source=e.source,this.name=t,this.mutants=e.mutants.map(e=>{let t=new Ze(e);return t.sourceFile=this,t})}getMutationLines(e){let t=this.getLineMap(),n=t[e.location.start.line],r=t[e.location.end.line],i=t[e.location.end.line+1];return`${this.source.substr(n,e.location.start.column-1)}${e.replacement??e.description??e.mutatorName}${this.source.substring(r+e.location.end.column-1,i)}`}},tt=class{parent;name;file;childResults;metrics;constructor(e,t,n,r){this.name=e,this.childResults=t,this.metrics=n,this.file=r}updateParent(e){this.parent=e,this.childResults.forEach(e=>e.updateParent(this))}updateAllMetrics(){if(this.parent!==void 0){this.parent.updateAllMetrics();return}this.updateMetrics()}updateMetrics(){if(this.file===void 0){this.childResults.forEach(e=>{e.updateMetrics()});let e=this.#e(this.childResults);if(e.length===0)return;e[0].tests?this.metrics=ht(e):this.metrics=gt(e);return}this.file.tests?this.metrics=ht([this.file]):this.metrics=gt([this.file])}#e(e){let t=[];return e.length===0||e.forEach(e=>{if(e.file){t.push(e.file);return}t.push(...this.#e(e.childResults))}),t}};function nt(e){if(e===void 0)throw Error(`test.sourceFile was not defined`)}function rt(e){if(e===void 0)throw Error(`test.location was not defined`)}var z={Killing:`Killing`,Covering:`Covering`,NotCovering:`NotCovering`},it=class{id;name;location;get killedMutants(){if(this.#e.size)return Array.from(this.#e.values())}get coveredMutants(){if(this.#t.size)return Array.from(this.#t.values())}#e=new Map;#t=new Map;addCovered(e){this.#t.set(e.id,e)}addKilled(e){this.#e.set(e.id,e)}constructor(e){Object.entries(e).forEach(([e,t])=>{this[e]=t})}getLines(){return nt(this.sourceFile),rt(this.location),this.sourceFile.getLines(this.location)}get fileName(){return nt(this.sourceFile),this.sourceFile.name}get status(){return this.#e.size?z.Killing:this.#t.size?z.Covering:z.NotCovering}update(){this.sourceFile?.result?.file&&this.sourceFile.result.updateAllMetrics()}},at=class extends $e{tests;source;result;name;constructor(e,t){super(),this.name=t,this.source=e.source,this.tests=e.tests.map(e=>{let t=new it(e);return t.sourceFile=this,t})}},ot=NaN,st=`All files`,ct=`All tests`;function lt(e){let{files:t,testFiles:n,projectRoot:r=``}=e,i=Ve(t,r,(e,t)=>new et(e,t));if(n&&Object.keys(n).length){let e=Ve(n,r,(e,t)=>new at(e,t));return mt(Object.values(i).flatMap(e=>e.mutants),Object.values(e).flatMap(e=>e.tests)),{systemUnderTestMetrics:ut(st,i,gt),testMetrics:ut(ct,e,ht)}}return{systemUnderTestMetrics:ut(st,i,gt),testMetrics:void 0}}function ut(e,t,n){let r=Object.keys(t);return r.length===1&&r[0]===``?ft(e,t[r[0]],n):dt(e,t,n)}function dt(e,t,n){let r=n(Object.values(t));return new tt(e,pt(t,n),r)}function ft(e,t,n){return new tt(e,[],n([t]),t)}function pt(e,t){let n=Ye(Object.entries(e),e=>e[0].split(`/`)[0]);return Object.keys(n).map(e=>{if(n[e].length>1||n[e]?.[0][0]!==e)return dt(e,n[e].reduce((t,[n,r])=>(t[n.substr(e.length+1)]=r,t),{}),t);{let[r,i]=n[e][0];return ft(r,i,t)}}).sort(We)}function mt(e,t){let n=new Map(t.map(e=>[e.id,e]));for(let t of e){let e=t.coveredBy??[];for(let r of e){let e=n.get(r);e&&(t.addCoveredBy(e),e.addCovered(t))}let r=t.killedBy??[];for(let e of r){let r=n.get(e);r&&(t.addKilledBy(r),r.addKilled(t))}}}function ht(e){let t=e.flatMap(e=>e.tests),n=e=>t.filter(t=>t.status===e).length;return{total:t.length,killing:n(z.Killing),covering:n(z.Covering),notCovering:n(z.NotCovering)}}function gt(e){let t=e.flatMap(e=>e.mutants),n=e=>t.filter(t=>t.status===e).length,r=n(`Pending`),i=n(`Killed`),a=n(`Timeout`),o=n(`Survived`),s=n(`NoCoverage`),c=n(`RuntimeError`),l=n(`CompileError`),u=n(`Ignored`),d=a+i,f=o+s,p=d+o,m=f+d,h=c+l;return{pending:r,killed:i,timeout:a,survived:o,noCoverage:s,runtimeErrors:c,compileErrors:l,ignored:u,totalDetected:d,totalUndetected:f,totalCovered:p,totalValid:m,totalInvalid:h,mutationScore:m>0?d/m*100:ot,totalMutants:m+h+u+r,mutationScoreBasedOnCoveredCode:m>0?d/p*100||0:ot}}var _t=function(e,t){return _t=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])},_t(e,t)};function vt(e,t){if(typeof t!=`function`&&t!==null)throw TypeError(`Class extends value `+String(t)+` is not a constructor or null`);_t(e,t);function n(){this.constructor=e}e.prototype=t===null?Object.create(t):(n.prototype=t.prototype,new n)}function yt(e,t,n,r){function i(e){return e instanceof n?e:new n(function(t){t(e)})}return new(n||=Promise)(function(n,a){function o(e){try{c(r.next(e))}catch(e){a(e)}}function s(e){try{c(r.throw(e))}catch(e){a(e)}}function c(e){e.done?n(e.value):i(e.value).then(o,s)}c((r=r.apply(e,t||[])).next())})}function bt(e,t){var n={label:0,sent:function(){if(a[0]&1)throw a[1];return a[1]},trys:[],ops:[]},r,i,a,o=Object.create((typeof Iterator==`function`?Iterator:Object).prototype);return o.next=s(0),o.throw=s(1),o.return=s(2),typeof Symbol==`function`&&(o[Symbol.iterator]=function(){return this}),o;function s(e){return function(t){return c([e,t])}}function c(s){if(r)throw TypeError(`Generator is already executing.`);for(;o&&(o=0,s[0]&&(n=0)),n;)try{if(r=1,i&&(a=s[0]&2?i.return:s[0]?i.throw||((a=i.return)&&a.call(i),0):i.next)&&!(a=a.call(i,s[1])).done)return a;switch(i=0,a&&(s=[s[0]&2,a.value]),s[0]){case 0:case 1:a=s;break;case 4:return n.label++,{value:s[1],done:!1};case 5:n.label++,i=s[1],s=[0];continue;case 7:s=n.ops.pop(),n.trys.pop();continue;default:if((a=n.trys,!(a=a.length>0&&a[a.length-1]))&&(s[0]===6||s[0]===2)){n=0;continue}if(s[0]===3&&(!a||s[1]>a[0]&&s[1]<a[3])){n.label=s[1];break}if(s[0]===6&&n.label<a[1]){n.label=a[1],a=s;break}if(a&&n.label<a[2]){n.label=a[2],n.ops.push(s);break}a[2]&&n.ops.pop(),n.trys.pop();continue}s=t.call(e,n)}catch(e){s=[6,e],i=0}finally{r=a=0}if(s[0]&5)throw s[1];return{value:s[0]?s[1]:void 0,done:!0}}}function xt(e){var t=typeof Symbol==`function`&&Symbol.iterator,n=t&&e[t],r=0;if(n)return n.call(e);if(e&&typeof e.length==`number`)return{next:function(){return e&&r>=e.length&&(e=void 0),{value:e&&e[r++],done:!e}}};throw TypeError(t?`Object is not iterable.`:`Symbol.iterator is not defined.`)}function St(e,t){var n=typeof Symbol==`function`&&e[Symbol.iterator];if(!n)return e;var r=n.call(e),i,a=[],o;try{for(;(t===void 0||t-- >0)&&!(i=r.next()).done;)a.push(i.value)}catch(e){o={error:e}}finally{try{i&&!i.done&&(n=r.return)&&n.call(r)}finally{if(o)throw o.error}}return a}function Ct(e,t,n){if(n||arguments.length===2)for(var r=0,i=t.length,a;r<i;r++)(a||!(r in t))&&(a||=Array.prototype.slice.call(t,0,r),a[r]=t[r]);return e.concat(a||Array.prototype.slice.call(t))}function wt(e){return this instanceof wt?(this.v=e,this):new wt(e)}function Tt(e,t,n){if(!Symbol.asyncIterator)throw TypeError(`Symbol.asyncIterator is not defined.`);var r=n.apply(e,t||[]),i,a=[];return i=Object.create((typeof AsyncIterator==`function`?AsyncIterator:Object).prototype),s(`next`),s(`throw`),s(`return`,o),i[Symbol.asyncIterator]=function(){return this},i;function o(e){return function(t){return Promise.resolve(t).then(e,d)}}function s(e,t){r[e]&&(i[e]=function(t){return new Promise(function(n,r){a.push([e,t,n,r])>1||c(e,t)})},t&&(i[e]=t(i[e])))}function c(e,t){try{l(r[e](t))}catch(e){f(a[0][3],e)}}function l(e){e.value instanceof wt?Promise.resolve(e.value.v).then(u,d):f(a[0][2],e)}function u(e){c(`next`,e)}function d(e){c(`throw`,e)}function f(e,t){e(t),a.shift(),a.length&&c(a[0][0],a[0][1])}}function Et(e){if(!Symbol.asyncIterator)throw TypeError(`Symbol.asyncIterator is not defined.`);var t=e[Symbol.asyncIterator],n;return t?t.call(e):(e=typeof xt==`function`?xt(e):e[Symbol.iterator](),n={},r(`next`),r(`throw`),r(`return`),n[Symbol.asyncIterator]=function(){return this},n);function r(t){n[t]=e[t]&&function(n){return new Promise(function(r,a){n=e[t](n),i(r,a,n.done,n.value)})}}function i(e,t,n,r){Promise.resolve(r).then(function(t){e({value:t,done:n})},t)}}function B(e){return typeof e==`function`}function Dt(e){var t=e(function(e){Error.call(e),e.stack=Error().stack});return t.prototype=Object.create(Error.prototype),t.prototype.constructor=t,t}var Ot=Dt(function(e){return function(t){e(this),this.message=t?t.length+` errors occurred during unsubscription:
8
+ `+t.map(function(e,t){return t+1+`) `+e.toString()}).join(`
9
+ `):``,this.name=`UnsubscriptionError`,this.errors=t}});function kt(e,t){if(e){var n=e.indexOf(t);0<=n&&e.splice(n,1)}}var At=function(){function e(e){this.initialTeardown=e,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var e,t,n,r,i;if(!this.closed){this.closed=!0;var a=this._parentage;if(a)if(this._parentage=null,Array.isArray(a))try{for(var o=xt(a),s=o.next();!s.done;s=o.next())s.value.remove(this)}catch(t){e={error:t}}finally{try{s&&!s.done&&(t=o.return)&&t.call(o)}finally{if(e)throw e.error}}else a.remove(this);var c=this.initialTeardown;if(B(c))try{c()}catch(e){i=e instanceof Ot?e.errors:[e]}var l=this._finalizers;if(l){this._finalizers=null;try{for(var u=xt(l),d=u.next();!d.done;d=u.next()){var f=d.value;try{Nt(f)}catch(e){i??=[],e instanceof Ot?i=Ct(Ct([],St(i)),St(e.errors)):i.push(e)}}}catch(e){n={error:e}}finally{try{d&&!d.done&&(r=u.return)&&r.call(u)}finally{if(n)throw n.error}}}if(i)throw new Ot(i)}},e.prototype.add=function(t){if(t&&t!==this)if(this.closed)Nt(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=this._finalizers??[]).push(t)}},e.prototype._hasParent=function(e){var t=this._parentage;return t===e||Array.isArray(t)&&t.includes(e)},e.prototype._addParent=function(e){var t=this._parentage;this._parentage=Array.isArray(t)?(t.push(e),t):t?[t,e]:e},e.prototype._removeParent=function(e){var t=this._parentage;t===e?this._parentage=null:Array.isArray(t)&&kt(t,e)},e.prototype.remove=function(t){var n=this._finalizers;n&&kt(n,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e}(),jt=At.EMPTY;function Mt(e){return e instanceof At||e&&`closed`in e&&B(e.remove)&&B(e.add)&&B(e.unsubscribe)}function Nt(e){B(e)?e():e.unsubscribe()}var Pt={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1},Ft={setTimeout:function(e,t){for(var n=[],r=2;r<arguments.length;r++)n[r-2]=arguments[r];var i=Ft.delegate;return i?.setTimeout?i.setTimeout.apply(i,Ct([e,t],St(n))):setTimeout.apply(void 0,Ct([e,t],St(n)))},clearTimeout:function(e){return(Ft.delegate?.clearTimeout||clearTimeout)(e)},delegate:void 0};function It(e){Ft.setTimeout(function(){var t=Pt.onUnhandledError;if(t)t(e);else throw e})}function Lt(){}var Rt=(function(){return Vt(`C`,void 0,void 0)})();function zt(e){return Vt(`E`,void 0,e)}function Bt(e){return Vt(`N`,e,void 0)}function Vt(e,t,n){return{kind:e,value:t,error:n}}var Ht=null;function Ut(e){if(Pt.useDeprecatedSynchronousErrorHandling){var t=!Ht;if(t&&(Ht={errorThrown:!1,error:null}),e(),t){var n=Ht,r=n.errorThrown,i=n.error;if(Ht=null,r)throw i}}else e()}function Wt(e){Pt.useDeprecatedSynchronousErrorHandling&&Ht&&(Ht.errorThrown=!0,Ht.error=e)}var Gt=function(e){vt(t,e);function t(t){var n=e.call(this)||this;return n.isStopped=!1,t?(n.destination=t,Mt(t)&&t.add(n)):n.destination=$t,n}return t.create=function(e,t,n){return new Yt(e,t,n)},t.prototype.next=function(e){this.isStopped?Qt(Bt(e),this):this._next(e)},t.prototype.error=function(e){this.isStopped?Qt(zt(e),this):(this.isStopped=!0,this._error(e))},t.prototype.complete=function(){this.isStopped?Qt(Rt,this):(this.isStopped=!0,this._complete())},t.prototype.unsubscribe=function(){this.closed||(this.isStopped=!0,e.prototype.unsubscribe.call(this),this.destination=null)},t.prototype._next=function(e){this.destination.next(e)},t.prototype._error=function(e){try{this.destination.error(e)}finally{this.unsubscribe()}},t.prototype._complete=function(){try{this.destination.complete()}finally{this.unsubscribe()}},t}(At),Kt=Function.prototype.bind;function qt(e,t){return Kt.call(e,t)}var Jt=function(){function e(e){this.partialObserver=e}return e.prototype.next=function(e){var t=this.partialObserver;if(t.next)try{t.next(e)}catch(e){Xt(e)}},e.prototype.error=function(e){var t=this.partialObserver;if(t.error)try{t.error(e)}catch(e){Xt(e)}else Xt(e)},e.prototype.complete=function(){var e=this.partialObserver;if(e.complete)try{e.complete()}catch(e){Xt(e)}},e}(),Yt=function(e){vt(t,e);function t(t,n,r){var i=e.call(this)||this,a;if(B(t)||!t)a={next:t??void 0,error:n??void 0,complete:r??void 0};else{var o;i&&Pt.useDeprecatedNextContext?(o=Object.create(t),o.unsubscribe=function(){return i.unsubscribe()},a={next:t.next&&qt(t.next,o),error:t.error&&qt(t.error,o),complete:t.complete&&qt(t.complete,o)}):a=t}return i.destination=new Jt(a),i}return t}(Gt);function Xt(e){Pt.useDeprecatedSynchronousErrorHandling?Wt(e):It(e)}function Zt(e){throw e}function Qt(e,t){var n=Pt.onStoppedNotification;n&&Ft.setTimeout(function(){return n(e,t)})}var $t={closed:!0,next:Lt,error:Zt,complete:Lt},en=(function(){return typeof Symbol==`function`&&Symbol.observable||`@@observable`})();function tn(e){return e}function nn(e){return e.length===0?tn:e.length===1?e[0]:function(t){return e.reduce(function(e,t){return t(e)},t)}}var V=function(){function e(e){e&&(this._subscribe=e)}return e.prototype.lift=function(t){var n=new e;return n.source=this,n.operator=t,n},e.prototype.subscribe=function(e,t,n){var r=this,i=on(e)?e:new Yt(e,t,n);return Ut(function(){var e=r,t=e.operator,n=e.source;i.add(t?t.call(i,n):n?r._subscribe(i):r._trySubscribe(i))}),i},e.prototype._trySubscribe=function(e){try{return this._subscribe(e)}catch(t){e.error(t)}},e.prototype.forEach=function(e,t){var n=this;return t=rn(t),new t(function(t,r){var i=new Yt({next:function(t){try{e(t)}catch(e){r(e),i.unsubscribe()}},error:r,complete:t});n.subscribe(i)})},e.prototype._subscribe=function(e){return this.source?.subscribe(e)},e.prototype[en]=function(){return this},e.prototype.pipe=function(){for(var e=[],t=0;t<arguments.length;t++)e[t]=arguments[t];return nn(e)(this)},e.prototype.toPromise=function(e){var t=this;return e=rn(e),new e(function(e,n){var r;t.subscribe(function(e){return r=e},function(e){return n(e)},function(){return e(r)})})},e.create=function(t){return new e(t)},e}();function rn(e){return e??Pt.Promise??Promise}function an(e){return e&&B(e.next)&&B(e.error)&&B(e.complete)}function on(e){return e&&e instanceof Gt||an(e)&&Mt(e)}function sn(e){return B(e?.lift)}function cn(e){return function(t){if(sn(t))return t.lift(function(t){try{return e(t,this)}catch(e){this.error(e)}});throw TypeError(`Unable to lift unknown Observable type`)}}function ln(e,t,n,r,i){return new un(e,t,n,r,i)}var un=function(e){vt(t,e);function t(t,n,r,i,a,o){var s=e.call(this,t)||this;return s.onFinalize=a,s.shouldUnsubscribe=o,s._next=n?function(e){try{n(e)}catch(e){t.error(e)}}:e.prototype._next,s._error=i?function(e){try{i(e)}catch(e){t.error(e)}finally{this.unsubscribe()}}:e.prototype._error,s._complete=r?function(){try{r()}catch(e){t.error(e)}finally{this.unsubscribe()}}:e.prototype._complete,s}return t.prototype.unsubscribe=function(){var t;if(!this.shouldUnsubscribe||this.shouldUnsubscribe()){var n=this.closed;e.prototype.unsubscribe.call(this),!n&&((t=this.onFinalize)==null||t.call(this))}},t}(Gt),dn=Dt(function(e){return function(){e(this),this.name=`ObjectUnsubscribedError`,this.message=`object unsubscribed`}}),fn=function(e){vt(t,e);function t(){var t=e.call(this)||this;return t.closed=!1,t.currentObservers=null,t.observers=[],t.isStopped=!1,t.hasError=!1,t.thrownError=null,t}return t.prototype.lift=function(e){var t=new pn(this,this);return t.operator=e,t},t.prototype._throwIfClosed=function(){if(this.closed)throw new dn},t.prototype.next=function(e){var t=this;Ut(function(){var n,r;if(t._throwIfClosed(),!t.isStopped){t.currentObservers||=Array.from(t.observers);try{for(var i=xt(t.currentObservers),a=i.next();!a.done;a=i.next())a.value.next(e)}catch(e){n={error:e}}finally{try{a&&!a.done&&(r=i.return)&&r.call(i)}finally{if(n)throw n.error}}}})},t.prototype.error=function(e){var t=this;Ut(function(){if(t._throwIfClosed(),!t.isStopped){t.hasError=t.isStopped=!0,t.thrownError=e;for(var n=t.observers;n.length;)n.shift().error(e)}})},t.prototype.complete=function(){var e=this;Ut(function(){if(e._throwIfClosed(),!e.isStopped){e.isStopped=!0;for(var t=e.observers;t.length;)t.shift().complete()}})},t.prototype.unsubscribe=function(){this.isStopped=this.closed=!0,this.observers=this.currentObservers=null},Object.defineProperty(t.prototype,`observed`,{get:function(){return this.observers?.length>0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(t){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,t)},t.prototype._subscribe=function(e){return this._throwIfClosed(),this._checkFinalizedStatuses(e),this._innerSubscribe(e)},t.prototype._innerSubscribe=function(e){var t=this,n=this,r=n.hasError,i=n.isStopped,a=n.observers;return r||i?jt:(this.currentObservers=null,a.push(e),new At(function(){t.currentObservers=null,kt(a,e)}))},t.prototype._checkFinalizedStatuses=function(e){var t=this,n=t.hasError,r=t.thrownError,i=t.isStopped;n?e.error(r):i&&e.complete()},t.prototype.asObservable=function(){var e=new V;return e.source=this,e},t.create=function(e,t){return new pn(e,t)},t}(V),pn=function(e){vt(t,e);function t(t,n){var r=e.call(this)||this;return r.destination=t,r.source=n,r}return t.prototype.next=function(e){var t,n;(n=(t=this.destination)?.next)==null||n.call(t,e)},t.prototype.error=function(e){var t,n;(n=(t=this.destination)?.error)==null||n.call(t,e)},t.prototype.complete=function(){var e,t;(t=(e=this.destination)?.complete)==null||t.call(e)},t.prototype._subscribe=function(e){return this.source?.subscribe(e)??jt},t}(fn),mn={now:function(){return(mn.delegate||Date).now()},delegate:void 0},hn=function(e){vt(t,e);function t(t,n){return e.call(this)||this}return t.prototype.schedule=function(e,t){return t===void 0&&(t=0),this},t}(At),gn={setInterval:function(e,t){for(var n=[],r=2;r<arguments.length;r++)n[r-2]=arguments[r];var i=gn.delegate;return i?.setInterval?i.setInterval.apply(i,Ct([e,t],St(n))):setInterval.apply(void 0,Ct([e,t],St(n)))},clearInterval:function(e){return(gn.delegate?.clearInterval||clearInterval)(e)},delegate:void 0},_n=function(e){vt(t,e);function t(t,n){var r=e.call(this,t,n)||this;return r.scheduler=t,r.work=n,r.pending=!1,r}return t.prototype.schedule=function(e,t){if(t===void 0&&(t=0),this.closed)return this;this.state=e;var n=this.id,r=this.scheduler;return n!=null&&(this.id=this.recycleAsyncId(r,n,t)),this.pending=!0,this.delay=t,this.id=this.id??this.requestAsyncId(r,this.id,t),this},t.prototype.requestAsyncId=function(e,t,n){return n===void 0&&(n=0),gn.setInterval(e.flush.bind(e,this),n)},t.prototype.recycleAsyncId=function(e,t,n){if(n===void 0&&(n=0),n!=null&&this.delay===n&&this.pending===!1)return t;t!=null&&gn.clearInterval(t)},t.prototype.execute=function(e,t){if(this.closed)return Error(`executing a cancelled action`);this.pending=!1;var n=this._execute(e,t);if(n)return n;this.pending===!1&&this.id!=null&&(this.id=this.recycleAsyncId(this.scheduler,this.id,null))},t.prototype._execute=function(e,t){var n=!1,r;try{this.work(e)}catch(e){n=!0,r=e||Error(`Scheduled action threw falsy error`)}if(n)return this.unsubscribe(),r},t.prototype.unsubscribe=function(){if(!this.closed){var t=this,n=t.id,r=t.scheduler,i=r.actions;this.work=this.state=this.scheduler=null,this.pending=!1,kt(i,this),n!=null&&(this.id=this.recycleAsyncId(r,n,null)),this.delay=null,e.prototype.unsubscribe.call(this)}},t}(hn),vn=function(){function e(t,n){n===void 0&&(n=e.now),this.schedulerActionCtor=t,this.now=n}return e.prototype.schedule=function(e,t,n){return t===void 0&&(t=0),new this.schedulerActionCtor(this,e).schedule(n,t)},e.now=mn.now,e}(),yn=new(function(e){vt(t,e);function t(t,n){n===void 0&&(n=vn.now);var r=e.call(this,t,n)||this;return r.actions=[],r._active=!1,r}return t.prototype.flush=function(e){var t=this.actions;if(this._active){t.push(e);return}var n;this._active=!0;do if(n=e.execute(e.state,e.delay))break;while(e=t.shift());if(this._active=!1,n){for(;e=t.shift();)e.unsubscribe();throw n}},t}(vn))(_n),bn=yn,xn=new V(function(e){return e.complete()});function Sn(e){return e&&B(e.schedule)}function Cn(e){return e[e.length-1]}function wn(e){return Sn(Cn(e))?e.pop():void 0}function Tn(e,t){return typeof Cn(e)==`number`?e.pop():t}var En=(function(e){return e&&typeof e.length==`number`&&typeof e!=`function`});function Dn(e){return B(e?.then)}function On(e){return B(e[en])}function kn(e){return Symbol.asyncIterator&&B(e?.[Symbol.asyncIterator])}function An(e){return TypeError(`You provided `+(typeof e==`object`&&e?`an invalid object`:`'`+e+`'`)+` where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`)}function jn(){return typeof Symbol!=`function`||!Symbol.iterator?`@@iterator`:Symbol.iterator}var Mn=jn();function Nn(e){return B(e?.[Mn])}function Pn(e){return Tt(this,arguments,function(){var t,n,r,i;return bt(this,function(a){switch(a.label){case 0:t=e.getReader(),a.label=1;case 1:a.trys.push([1,,9,10]),a.label=2;case 2:return[4,wt(t.read())];case 3:return n=a.sent(),r=n.value,i=n.done,i?[4,wt(void 0)]:[3,5];case 4:return[2,a.sent()];case 5:return[4,wt(r)];case 6:return[4,a.sent()];case 7:return a.sent(),[3,2];case 8:return[3,10];case 9:return t.releaseLock(),[7];case 10:return[2]}})})}function Fn(e){return B(e?.getReader)}function In(e){if(e instanceof V)return e;if(e!=null){if(On(e))return Ln(e);if(En(e))return Rn(e);if(Dn(e))return zn(e);if(kn(e))return Vn(e);if(Nn(e))return Bn(e);if(Fn(e))return Hn(e)}throw An(e)}function Ln(e){return new V(function(t){var n=e[en]();if(B(n.subscribe))return n.subscribe(t);throw TypeError(`Provided object does not correctly implement Symbol.observable`)})}function Rn(e){return new V(function(t){for(var n=0;n<e.length&&!t.closed;n++)t.next(e[n]);t.complete()})}function zn(e){return new V(function(t){e.then(function(e){t.closed||(t.next(e),t.complete())},function(e){return t.error(e)}).then(null,It)})}function Bn(e){return new V(function(t){var n,r;try{for(var i=xt(e),a=i.next();!a.done;a=i.next()){var o=a.value;if(t.next(o),t.closed)return}}catch(e){n={error:e}}finally{try{a&&!a.done&&(r=i.return)&&r.call(i)}finally{if(n)throw n.error}}t.complete()})}function Vn(e){return new V(function(t){Un(e,t).catch(function(e){return t.error(e)})})}function Hn(e){return Vn(Pn(e))}function Un(e,t){var n,r,i,a;return yt(this,void 0,void 0,function(){var o,s;return bt(this,function(c){switch(c.label){case 0:c.trys.push([0,5,6,11]),n=Et(e),c.label=1;case 1:return[4,n.next()];case 2:if(r=c.sent(),r.done)return[3,4];if(o=r.value,t.next(o),t.closed)return[2];c.label=3;case 3:return[3,1];case 4:return[3,11];case 5:return s=c.sent(),i={error:s},[3,11];case 6:return c.trys.push([6,,9,10]),r&&!r.done&&(a=n.return)?[4,a.call(n)]:[3,8];case 7:c.sent(),c.label=8;case 8:return[3,10];case 9:if(i)throw i.error;return[7];case 10:return[7];case 11:return t.complete(),[2]}})})}function Wn(e,t,n,r,i){r===void 0&&(r=0),i===void 0&&(i=!1);var a=t.schedule(function(){n(),i?e.add(this.schedule(null,r)):this.unsubscribe()},r);if(e.add(a),!i)return a}function Gn(e,t){return t===void 0&&(t=0),cn(function(n,r){n.subscribe(ln(r,function(n){return Wn(r,e,function(){return r.next(n)},t)},function(){return Wn(r,e,function(){return r.complete()},t)},function(n){return Wn(r,e,function(){return r.error(n)},t)}))})}function Kn(e,t){return t===void 0&&(t=0),cn(function(n,r){r.add(e.schedule(function(){return n.subscribe(r)},t))})}function qn(e,t){return In(e).pipe(Kn(t),Gn(t))}function Jn(e,t){return In(e).pipe(Kn(t),Gn(t))}function Yn(e,t){return new V(function(n){var r=0;return t.schedule(function(){r===e.length?n.complete():(n.next(e[r++]),n.closed||this.schedule())})})}function Xn(e,t){return new V(function(n){var r;return Wn(n,t,function(){r=e[Mn](),Wn(n,t,function(){var e,t,i;try{e=r.next(),t=e.value,i=e.done}catch(e){n.error(e);return}i?n.complete():n.next(t)},0,!0)}),function(){return B(r?.return)&&r.return()}})}function Zn(e,t){if(!e)throw Error(`Iterable cannot be null`);return new V(function(n){Wn(n,t,function(){var r=e[Symbol.asyncIterator]();Wn(n,t,function(){r.next().then(function(e){e.done?n.complete():n.next(e.value)})},0,!0)})})}function Qn(e,t){return Zn(Pn(e),t)}function $n(e,t){if(e!=null){if(On(e))return qn(e,t);if(En(e))return Yn(e,t);if(Dn(e))return Jn(e,t);if(kn(e))return Zn(e,t);if(Nn(e))return Xn(e,t);if(Fn(e))return Qn(e,t)}throw An(e)}function er(e,t){return t?$n(e,t):In(e)}function tr(){for(var e=[],t=0;t<arguments.length;t++)e[t]=arguments[t];return er(e,wn(e))}function nr(e){return e instanceof Date&&!isNaN(e)}function rr(e,t){return cn(function(n,r){var i=0;n.subscribe(ln(r,function(n){r.next(e.call(t,n,i++))}))})}var ir=Array.isArray;function ar(e,t){return ir(t)?e.apply(void 0,Ct([],St(t))):e(t)}function or(e){return rr(function(t){return ar(e,t)})}function sr(e,t,n,r,i,a,o,s){var c=[],l=0,u=0,d=!1,f=function(){d&&!c.length&&!l&&t.complete()},p=function(e){return l<r?m(e):c.push(e)},m=function(e){a&&t.next(e),l++;var s=!1;In(n(e,u++)).subscribe(ln(t,function(e){i?.(e),a?p(e):t.next(e)},function(){s=!0},void 0,function(){if(s)try{l--;for(var e=function(){var e=c.shift();o?Wn(t,o,function(){return m(e)}):m(e)};c.length&&l<r;)e();f()}catch(e){t.error(e)}}))};return e.subscribe(ln(t,p,function(){d=!0,f()})),function(){s?.()}}function cr(e,t,n){return n===void 0&&(n=1/0),B(t)?cr(function(n,r){return rr(function(e,i){return t(n,e,r,i)})(In(e(n,r)))},n):(typeof t==`number`&&(n=t),cn(function(t,r){return sr(t,r,e,n)}))}function lr(e){return e===void 0&&(e=1/0),cr(tn,e)}var ur=[`addListener`,`removeListener`],dr=[`addEventListener`,`removeEventListener`],fr=[`on`,`off`];function pr(e,t,n,r){if(B(n)&&(r=n,n=void 0),r)return pr(e,t,n).pipe(or(r));var i=St(_r(e)?dr.map(function(r){return function(i){return e[r](t,i,n)}}):hr(e)?ur.map(mr(e,t)):gr(e)?fr.map(mr(e,t)):[],2),a=i[0],o=i[1];if(!a&&En(e))return cr(function(e){return pr(e,t,n)})(In(e));if(!a)throw TypeError(`Invalid event target`);return new V(function(e){var t=function(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return e.next(1<t.length?t:t[0])};return a(t),function(){return o(t)}})}function mr(e,t){return function(n){return function(r){return e[n](t,r)}}}function hr(e){return B(e.addListener)&&B(e.removeListener)}function gr(e){return B(e.on)&&B(e.off)}function _r(e){return B(e.addEventListener)&&B(e.removeEventListener)}function vr(e,t,n){e===void 0&&(e=0),n===void 0&&(n=bn);var r=-1;return t!=null&&(Sn(t)?n=t:r=t),new V(function(t){var i=nr(e)?+e-n.now():e;i<0&&(i=0);var a=0;return n.schedule(function(){t.closed||(t.next(a++),0<=r?this.schedule(void 0,r):t.complete())},i)})}function yr(e,t){return e===void 0&&(e=0),t===void 0&&(t=yn),e<0&&(e=0),vr(e,e,t)}function br(){for(var e=[],t=0;t<arguments.length;t++)e[t]=arguments[t];var n=wn(e),r=Tn(e,1/0),i=e;return i.length?i.length===1?In(i[0]):lr(r)(er(i,n)):xn}function xr(e,t){return t===void 0&&(t=tn),e??=Sr,cn(function(n,r){var i,a=!0;n.subscribe(ln(r,function(n){var o=t(n);(a||!e(i,o))&&(a=!1,i=o,r.next(n))}))})}function Sr(e,t){return e===t}function Cr(e){return cn(function(t,n){var r=!1,i=null;t.subscribe(ln(n,function(e){r=!0,i=e})),In(e).subscribe(ln(n,function(){if(r){r=!1;var e=i;i=null,n.next(e)}},Lt))})}function wr(e,t){return t===void 0&&(t=yn),Cr(yr(e,t))}function Tr(e,t,n){var r=B(e)||t||n?{next:e,error:t,complete:n}:e;return r?cn(function(e,t){var n;(n=r.subscribe)==null||n.call(r);var i=!0;e.subscribe(ln(t,function(e){var n;(n=r.next)==null||n.call(r,e),t.next(e)},function(){var e;i=!1,(e=r.complete)==null||e.call(r),t.complete()},function(e){var n;i=!1,(n=r.error)==null||n.call(r,e),t.error(e)},function(){var e,t;i&&((e=r.unsubscribe)==null||e.call(r)),(t=r.finalize)==null||t.call(r)}))}):tn}function Er(){let e=`test`;try{return localStorage.setItem(e,e),localStorage.removeItem(e),!0}catch{return!1}}function H(e,t,n){return new CustomEvent(e,{detail:t,...n})}var{I:Dr}=ke,Or=e=>e,kr=e=>e.strings===void 0,Ar=()=>document.createComment(``),jr=(e,t,n)=>{let r=e._$AA.parentNode,i=t===void 0?e._$AB:t._$AA;if(n===void 0)n=new Dr(r.insertBefore(Ar(),i),r.insertBefore(Ar(),i),e,e.options);else{let t=n._$AB.nextSibling,a=n._$AM,o=a!==e;if(o){let t;n._$AQ?.(e),n._$AM=e,n._$AP!==void 0&&(t=e._$AU)!==a._$AU&&n._$AP(t)}if(t!==i||o){let e=n._$AA;for(;e!==t;){let t=Or(e).nextSibling;Or(r).insertBefore(e,i),e=t}}}return n},Mr=(e,t,n=e)=>(e._$AI(t,n),e),Nr={},Pr=(e,t=Nr)=>e._$AH=t,Fr=e=>e._$AH,Ir=e=>{e._$AR(),e._$AA.remove()},Lr={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},Rr=e=>(...t)=>({_$litDirective$:e,values:t}),zr=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}},Br=(e,t)=>{let n=e._$AN;if(n===void 0)return!1;for(let e of n)e._$AO?.(t,!1),Br(e,t);return!0},Vr=e=>{let t,n;do{if((t=e._$AM)===void 0)break;n=t._$AN,n.delete(e),e=t}while(n?.size===0)},Hr=e=>{for(let t;t=e._$AM;e=t){let n=t._$AN;if(n===void 0)t._$AN=n=new Set;else if(n.has(e))break;n.add(e),Gr(t)}};function Ur(e){this._$AN===void 0?this._$AM=e:(Vr(this),this._$AM=e,Hr(this))}function Wr(e,t=!1,n=0){let r=this._$AH,i=this._$AN;if(i!==void 0&&i.size!==0)if(t)if(Array.isArray(r))for(let e=n;e<r.length;e++)Br(r[e],!1),Vr(r[e]);else r!=null&&(Br(r,!1),Vr(r));else Br(this,e)}var Gr=e=>{e.type==Lr.CHILD&&(e._$AP??=Wr,e._$AQ??=Ur)},Kr=class extends zr{constructor(){super(...arguments),this._$AN=void 0}_$AT(e,t,n){super._$AT(e,t,n),Hr(this),this.isConnected=e._$AU}_$AO(e,t=!0){e!==this.isConnected&&(this.isConnected=e,e?this.reconnected?.():this.disconnected?.()),t&&(Br(this,e),Vr(this))}setValue(e){if(kr(this._$Ct))this._$Ct._$AI(e,this);else{let t=[...this._$Ct._$AH];t[this._$Ci]=e,this._$Ct._$AI(t,this,0)}}disconnected(){}reconnected(){}},qr=class{constructor(e,{target:t,config:n,callback:r,skipInitial:i}){this.t=new Set,this.o=!1,this.i=!1,this.h=e,t!==null&&this.t.add(t??e),this.l=n,this.o=i??this.o,this.callback=r,window.ResizeObserver?(this.u=new ResizeObserver(e=>{this.handleChanges(e),this.h.requestUpdate()}),e.addController(this)):console.warn(`ResizeController error: browser does not support ResizeObserver.`)}handleChanges(e){this.value=this.callback?.(e,this.u)}hostConnected(){for(let e of this.t)this.observe(e)}hostDisconnected(){this.disconnect()}async hostUpdated(){!this.o&&this.i&&this.handleChanges([]),this.i=!1}observe(e){this.t.add(e),this.u.observe(e,this.l),this.i=!0,this.h.requestUpdate()}unobserve(e){this.t.delete(e),this.u.unobserve(e)}disconnect(){this.u.disconnect()}target(e){return Jr(this,e)}},Jr=Rr(class extends Kr{constructor(){super(...arguments),this.observing=!1}render(e,t){}update(e,[t,n]){this.controller=t,this.part=e,this.observe=n,!1===n?(t.unobserve(e.element),this.observing=!1):!1===this.observing&&(t.observe(e.element),this.observing=!0)}disconnected(){this.controller?.unobserve(this.part.element),this.observing=!1}reconnected(){!1!==this.observe&&!1===this.observing&&(this.controller?.observe(this.part.element),this.observing=!0)}}),Yr=Rr(class extends zr{constructor(e){if(super(e),e.type!==Lr.ATTRIBUTE||e.name!==`class`||e.strings?.length>2)throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.")}render(e){return` `+Object.keys(e).filter(t=>e[t]).join(` `)+` `}update(e,[t]){if(this.st===void 0){this.st=new Set,e.strings!==void 0&&(this.nt=new Set(e.strings.join(` `).split(/\s/).filter(e=>e!==``)));for(let e in t)t[e]&&!this.nt?.has(e)&&this.st.add(e);return this.render(t)}let n=e.element.classList;for(let e of this.st)e in t||(n.remove(e),this.st.delete(e));for(let e in t){let r=!!t[e];r===this.st.has(e)||this.nt?.has(e)||(r?(n.add(e),this.st.add(e)):(n.remove(e),this.st.delete(e)))}return N}}),Xr=t(((e,t)=>{var n=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,r={},i={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof a?new a(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/\u00a0/g,` `)},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,`__id`,{value:++n}),e.__id},clone:function e(t,n){n||={};var r,a;switch(i.util.type(t)){case`Object`:if(a=i.util.objId(t),n[a])return n[a];for(var o in r={},n[a]=r,t)t.hasOwnProperty(o)&&(r[o]=e(t[o],n));return r;case`Array`:return a=i.util.objId(t),n[a]?n[a]:(r=[],n[a]=r,t.forEach(function(t,i){r[i]=e(t,n)}),r);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return`none`},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,`gi`),``),e.classList.add(`language-`+n)},currentScript:function(){if(typeof document>`u`)return null;if(document.currentScript&&document.currentScript.tagName===`SCRIPT`)return document.currentScript;try{throw Error()}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var t=document.getElementsByTagName(`script`);for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var r=`no-`+t;e;){var i=e.classList;if(i.contains(t))return!0;if(i.contains(r))return!1;e=e.parentElement}return!!n}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,t){var n=i.util.clone(i.languages[e]);for(var r in t)n[r]=t[r];return n},insertBefore:function(e,t,n,r){r||=i.languages;var a=r[e],o={};for(var s in a)if(a.hasOwnProperty(s)){if(s==t)for(var c in n)n.hasOwnProperty(c)&&(o[c]=n[c]);n.hasOwnProperty(s)||(o[s]=a[s])}var l=r[e];return r[e]=o,i.languages.DFS(i.languages,function(t,n){n===l&&t!=e&&(this[t]=o)}),o},DFS:function e(t,n,r,a){a||={};var o=i.util.objId;for(var s in t)if(t.hasOwnProperty(s)){n.call(t,s,t[s],r||s);var c=t[s],l=i.util.type(c);l===`Object`&&!a[o(c)]?(a[o(c)]=!0,e(c,n,null,a)):l===`Array`&&!a[o(c)]&&(a[o(c)]=!0,e(c,n,s,a))}}},plugins:{},highlightAll:function(e,t){i.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var r={callback:n,container:e,selector:`code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code`};i.hooks.run(`before-highlightall`,r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),i.hooks.run(`before-all-elements-highlight`,r);for(var a=0,o;o=r.elements[a++];)i.highlightElement(o,t===!0,r.callback)},highlightElement:function(t,n,r){var a=i.util.getLanguage(t),o=i.languages[a];i.util.setLanguage(t,a);var s=t.parentElement;s&&s.nodeName.toLowerCase()===`pre`&&i.util.setLanguage(s,a);var c={element:t,language:a,grammar:o,code:t.textContent};function l(e){c.highlightedCode=e,i.hooks.run(`before-insert`,c),c.element.innerHTML=c.highlightedCode,i.hooks.run(`after-highlight`,c),i.hooks.run(`complete`,c),r&&r.call(c.element)}if(i.hooks.run(`before-sanity-check`,c),s=c.element.parentElement,s&&s.nodeName.toLowerCase()===`pre`&&!s.hasAttribute(`tabindex`)&&s.setAttribute(`tabindex`,`0`),!c.code){i.hooks.run(`complete`,c),r&&r.call(c.element);return}if(i.hooks.run(`before-highlight`,c),!c.grammar){l(i.util.encode(c.code));return}if(n&&e.Worker){var u=new Worker(i.filename);u.onmessage=function(e){l(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else l(i.highlight(c.code,c.grammar,c.language))},highlight:function(e,t,n){var r={code:e,grammar:t,language:n};if(i.hooks.run(`before-tokenize`,r),!r.grammar)throw Error(`The language "`+r.language+`" has no grammar.`);return r.tokens=i.tokenize(r.code,r.grammar),i.hooks.run(`after-tokenize`,r),a.stringify(i.util.encode(r.tokens),r.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var r in n)t[r]=n[r];delete t.rest}var i=new c;return l(i,i.head,e),s(e,i,t,i.head,0),d(i)},hooks:{all:{},add:function(e,t){var n=i.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=i.hooks.all[e];if(!(!n||!n.length))for(var r=0,a;a=n[r++];)a(t)}},Token:a};e.Prism=i;function a(e,t,n,r){this.type=e,this.content=t,this.alias=n,this.length=(r||``).length|0}a.stringify=function e(t,n){if(typeof t==`string`)return t;if(Array.isArray(t)){var r=``;return t.forEach(function(t){r+=e(t,n)}),r}var a={type:t.type,content:e(t.content,n),tag:`span`,classes:[`token`,t.type],attributes:{},language:n},o=t.alias;o&&(Array.isArray(o)?Array.prototype.push.apply(a.classes,o):a.classes.push(o)),i.hooks.run(`wrap`,a);var s=``;for(var c in a.attributes)s+=` `+c+`="`+(a.attributes[c]||``).replace(/"/g,`&quot;`)+`"`;return`<`+a.tag+` class="`+a.classes.join(` `)+`"`+s+`>`+a.content+`</`+a.tag+`>`};function o(e,t,n,r){e.lastIndex=t;var i=e.exec(n);if(i&&r&&i[1]){var a=i[1].length;i.index+=a,i[0]=i[0].slice(a)}return i}function s(e,t,n,r,c,d){for(var f in n)if(!(!n.hasOwnProperty(f)||!n[f])){var p=n[f];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(d&&d.cause==f+`,`+m)return;var h=p[m],g=h.inside,_=!!h.lookbehind,v=!!h.greedy,y=h.alias;if(v&&!h.pattern.global){var b=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,b+`g`)}for(var x=h.pattern||h,S=r.next,C=c;S!==t.tail&&!(d&&C>=d.reach);C+=S.value.length,S=S.next){var w=S.value;if(t.length>e.length)return;if(!(w instanceof a)){var T=1,E;if(v){if(E=o(x,C,e,_),!E||E.index>=e.length)break;var D=E.index,ee=E.index+E[0].length,O=C;for(O+=S.value.length;D>=O;)S=S.next,O+=S.value.length;if(O-=S.value.length,C=O,S.value instanceof a)continue;for(var k=S;k!==t.tail&&(O<ee||typeof k.value==`string`);k=k.next)T++,O+=k.value.length;T--,w=e.slice(C,O),E.index-=C}else if(E=o(x,0,w,_),!E)continue;var D=E.index,te=E[0],ne=w.slice(0,D),A=w.slice(D+te.length),re=C+w.length;d&&re>d.reach&&(d.reach=re);var ie=S.prev;ne&&(ie=l(t,ie,ne),C+=ne.length),u(t,ie,T);var ae=new a(f,g?i.tokenize(te,g):te,y,te);if(S=l(t,ie,ae),A&&l(t,S,A),T>1){var oe={cause:f+`,`+m,reach:re};s(e,t,n,S.prev,C,oe),d&&oe.reach>d.reach&&(d.reach=oe.reach)}}}}}}function c(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function l(e,t,n){var r=t.next,i={value:n,prev:t,next:r};return t.next=i,r.prev=i,e.length++,i}function u(e,t,n){for(var r=t.next,i=0;i<n&&r!==e.tail;i++)r=r.next;t.next=r,r.prev=t,e.length-=i}function d(e){for(var t=[],n=e.head.next;n!==e.tail;)t.push(n.value),n=n.next;return t}if(!e.document)return e.addEventListener&&(i.disableWorkerMessageHandler||e.addEventListener(`message`,function(t){var n=JSON.parse(t.data),r=n.language,a=n.code,o=n.immediateClose;e.postMessage(i.highlight(a,i.languages[r],r)),o&&e.close()},!1)),i;var f=i.util.currentScript();f&&(i.filename=f.src,f.hasAttribute(`data-manual`)&&(i.manual=!0));function p(){i.manual||i.highlightAll()}if(!i.manual){var m=document.readyState;m===`loading`||m===`interactive`&&f&&f.defer?document.addEventListener(`DOMContentLoaded`,p):window.requestAnimationFrame?window.requestAnimationFrame(p):window.setTimeout(p,16)}return i}(typeof window<`u`?window:typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope?self:{});t!==void 0&&t.exports&&(t.exports=n),typeof global<`u`&&(global.Prism=n)}))();Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/},Prism.languages.javascript=Prism.languages.extend(`clike`,{"class-name":[Prism.languages.clike[`class-name`],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp(`(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])`),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript[`class-name`][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore(`javascript`,`keyword`,{regex:{pattern:RegExp(`((?:^|[^$\\w\\xA0-\\uFFFF."'\\])\\s]|\\b(?:return|yield))\\s*)\\/(?:(?:\\[(?:[^\\]\\\\\\r\\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.|\\[(?:[^[\\]\\\\\\r\\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\\r\\n])+\\/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/)*(?:$|[\\r\\n,.;:})\\]]|\\/\\/))`),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:`language-regex`,inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:`function`},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore(`javascript`,`string`,{hashbang:{pattern:/^#!.*/,greedy:!0,alias:`comment`},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:`string`},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:`punctuation`},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:`property`}}),Prism.languages.insertBefore(`javascript`,`operator`,{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:`property`}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined(`script`,`javascript`),Prism.languages.markup.tag.addAttribute(`on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)`,`javascript`)),Prism.languages.js=Prism.languages.javascript,(function(e){e.languages.typescript=e.languages.extend(`javascript`,{"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|type)\s+)(?!keyof\b)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?:\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?/,lookbehind:!0,greedy:!0,inside:null},builtin:/\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\b/}),e.languages.typescript.keyword.push(/\b(?:abstract|declare|is|keyof|readonly|require)\b/,/\b(?:asserts|infer|interface|module|namespace|type)\b(?=\s*(?:[{_$a-zA-Z\xA0-\uFFFF]|$))/,/\btype\b(?=\s*(?:[\{*]|$))/),delete e.languages.typescript.parameter,delete e.languages.typescript[`literal-property`];var t=e.languages.extend(`typescript`,{});delete t[`class-name`],e.languages.typescript[`class-name`].inside=t,e.languages.insertBefore(`typescript`,`function`,{decorator:{pattern:/@[$\w\xA0-\uFFFF]+/,inside:{at:{pattern:/^@/,alias:`operator`},function:/^[\s\S]+/}},"generic-function":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\s*\()/,greedy:!0,inside:{function:/^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/,generic:{pattern:/<[\s\S]+/,alias:`class-name`,inside:t}}}}),e.languages.ts=e.languages.typescript})(Prism),(function(e){function t(e,t){return e.replace(/<<(\d+)>>/g,function(e,n){return`(?:`+t[+n]+`)`})}function n(e,n,r){return RegExp(t(e,n),r||``)}function r(e,t){for(var n=0;n<t;n++)e=e.replace(/<<self>>/g,function(){return`(?:`+e+`)`});return e.replace(/<<self>>/g,`[^\\s\\S]`)}var i={type:`bool byte char decimal double dynamic float int long object sbyte short string uint ulong ushort var void`,typeDeclaration:`class enum interface record struct`,contextual:`add alias and ascending async await by descending from(?=\\s*(?:\\w|$)) get global group into init(?=\\s*;) join let nameof not notnull on or orderby partial remove select set unmanaged value when where with(?=\\s*{)`,other:`abstract as base break case catch checked const continue default delegate do else event explicit extern finally fixed for foreach goto if implicit in internal is lock namespace new null operator out override params private protected public readonly ref return sealed sizeof stackalloc static switch this throw try typeof unchecked unsafe using virtual volatile while yield`};function a(e){return`\\b(?:`+e.trim().replace(/ /g,`|`)+`)\\b`}var o=a(i.typeDeclaration),s=RegExp(a(i.type+` `+i.typeDeclaration+` `+i.contextual+` `+i.other)),c=a(i.typeDeclaration+` `+i.contextual+` `+i.other),l=a(i.type+` `+i.typeDeclaration+` `+i.other),u=r(`<(?:[^<>;=+\\-*/%&|^]|<<self>>)*>`,2),d=r(`\\((?:[^()]|<<self>>)*\\)`,2),f=`@?\\b[A-Za-z_]\\w*\\b`,p=t(`<<0>>(?:\\s*<<1>>)?`,[f,u]),m=t(`(?!<<0>>)<<1>>(?:\\s*\\.\\s*<<1>>)*`,[c,p]),h=`\\[\\s*(?:,\\s*)*\\]`,g=t(`<<0>>(?:\\s*(?:\\?\\s*)?<<1>>)*(?:\\s*\\?)?`,[m,h]),_=t(`(?:<<0>>|<<1>>)(?:\\s*(?:\\?\\s*)?<<2>>)*(?:\\s*\\?)?`,[t(`\\(<<0>>+(?:,<<0>>+)+\\)`,[t(`[^,()<>[\\];=+\\-*/%&|^]|<<0>>|<<1>>|<<2>>`,[u,d,h])]),m,h]),v={keyword:s,punctuation:/[<>()?,.:[\]]/},y=`'(?:[^\\r\\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'`,b=`"(?:\\\\.|[^\\\\"\\r\\n])*"`,x=`@"(?:""|\\\\[\\s\\S]|[^\\\\"])*"(?!")`;e.languages.csharp=e.languages.extend(`clike`,{string:[{pattern:n(`(^|[^$\\\\])<<0>>`,[x]),lookbehind:!0,greedy:!0},{pattern:n(`(^|[^@$\\\\])<<0>>`,[b]),lookbehind:!0,greedy:!0}],"class-name":[{pattern:n(`(\\busing\\s+static\\s+)<<0>>(?=\\s*;)`,[m]),lookbehind:!0,inside:v},{pattern:n(`(\\busing\\s+<<0>>\\s*=\\s*)<<1>>(?=\\s*;)`,[f,_]),lookbehind:!0,inside:v},{pattern:n(`(\\busing\\s+)<<0>>(?=\\s*=)`,[f]),lookbehind:!0},{pattern:n(`(\\b<<0>>\\s+)<<1>>`,[o,p]),lookbehind:!0,inside:v},{pattern:n(`(\\bcatch\\s*\\(\\s*)<<0>>`,[m]),lookbehind:!0,inside:v},{pattern:n(`(\\bwhere\\s+)<<0>>`,[f]),lookbehind:!0},{pattern:n(`(\\b(?:is(?:\\s+not)?|as)\\s+)<<0>>`,[g]),lookbehind:!0,inside:v},{pattern:n(`\\b<<0>>(?=\\s+(?!<<1>>|with\\s*\\{)<<2>>(?:\\s*[=,;:{)\\]]|\\s+(?:in|when)\\b))`,[_,l,f]),inside:v}],keyword:s,number:/(?:\b0(?:x[\da-f_]*[\da-f]|b[01_]*[01])|(?:\B\.\d+(?:_+\d+)*|\b\d+(?:_+\d+)*(?:\.\d+(?:_+\d+)*)?)(?:e[-+]?\d+(?:_+\d+)*)?)(?:[dflmu]|lu|ul)?\b/i,operator:/>>=?|<<=?|[-=]>|([-+&|])\1|~|\?\?=?|[-+*/%&|^!=<>]=?/,punctuation:/\?\.?|::|[{}[\];(),.:]/}),e.languages.insertBefore(`csharp`,`number`,{range:{pattern:/\.\./,alias:`operator`}}),e.languages.insertBefore(`csharp`,`punctuation`,{"named-parameter":{pattern:n(`([(,]\\s*)<<0>>(?=\\s*:)`,[f]),lookbehind:!0,alias:`punctuation`}}),e.languages.insertBefore(`csharp`,`class-name`,{namespace:{pattern:n(`(\\b(?:namespace|using)\\s+)<<0>>(?:\\s*\\.\\s*<<0>>)*(?=\\s*[;{])`,[f]),lookbehind:!0,inside:{punctuation:/\./}},"type-expression":{pattern:n(`(\\b(?:default|sizeof|typeof)\\s*\\(\\s*(?!\\s))(?:[^()\\s]|\\s(?!\\s)|<<0>>)*(?=\\s*\\))`,[d]),lookbehind:!0,alias:`class-name`,inside:v},"return-type":{pattern:n(`<<0>>(?=\\s+(?:<<1>>\\s*(?:=>|[({]|\\.\\s*this\\s*\\[)|this\\s*\\[))`,[_,m]),inside:v,alias:`class-name`},"constructor-invocation":{pattern:n(`(\\bnew\\s+)<<0>>(?=\\s*[[({])`,[_]),lookbehind:!0,inside:v,alias:`class-name`},"generic-method":{pattern:n(`<<0>>\\s*<<1>>(?=\\s*\\()`,[f,u]),inside:{function:n(`^<<0>>`,[f]),generic:{pattern:RegExp(u),alias:`class-name`,inside:v}}},"type-list":{pattern:n(`\\b((?:<<0>>\\s+<<1>>|record\\s+<<1>>\\s*<<5>>|where\\s+<<2>>)\\s*:\\s*)(?:<<3>>|<<4>>|<<1>>\\s*<<5>>|<<6>>)(?:\\s*,\\s*(?:<<3>>|<<4>>|<<6>>))*(?=\\s*(?:where|[{;]|=>|$))`,[o,p,f,_,s.source,d,`\\bnew\\s*\\(\\s*\\)`]),lookbehind:!0,inside:{"record-arguments":{pattern:n(`(^(?!new\\s*\\()<<0>>\\s*)<<1>>`,[p,d]),lookbehind:!0,greedy:!0,inside:e.languages.csharp},keyword:s,"class-name":{pattern:RegExp(_),greedy:!0,inside:v},punctuation:/[,()]/}},preprocessor:{pattern:/(^[\t ]*)#.*/m,lookbehind:!0,alias:`property`,inside:{directive:{pattern:/(#)\b(?:define|elif|else|endif|endregion|error|if|line|nullable|pragma|region|undef|warning)\b/,lookbehind:!0,alias:`keyword`}}}});var S=b+`|`+y,C=t(`\\/(?![*/])|\\/\\/[^\\r\\n]*[\\r\\n]|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|<<0>>`,[S]),w=r(t(`[^"'/()]|<<0>>|\\(<<self>>*\\)`,[C]),2),T=`\\b(?:assembly|event|field|method|module|param|property|return|type)\\b`,E=t(`<<0>>(?:\\s*\\(<<1>>*\\))?`,[m,w]);e.languages.insertBefore(`csharp`,`class-name`,{attribute:{pattern:n(`((?:^|[^\\s\\w>)?])\\s*\\[\\s*)(?:<<0>>\\s*:\\s*)?<<1>>(?:\\s*,\\s*<<1>>)*(?=\\s*\\])`,[T,E]),lookbehind:!0,greedy:!0,inside:{target:{pattern:n(`^<<0>>(?=\\s*:)`,[T]),alias:`keyword`},"attribute-arguments":{pattern:n(`\\(<<0>>*\\)`,[w]),inside:e.languages.csharp},"class-name":{pattern:RegExp(m),inside:{punctuation:/\./}},punctuation:/[:,]/}}});var D=`:[^}\\r\\n]+`,ee=r(t(`[^"'/()]|<<0>>|\\(<<self>>*\\)`,[C]),2),O=t(`\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}`,[ee,D]),k=r(t(`[^"'/()]|\\/(?!\\*)|\\/\\*(?:[^*]|\\*(?!\\/))*\\*\\/|<<0>>|\\(<<self>>*\\)`,[S]),2),te=t(`\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}`,[k,D]);function ne(t,r){return{interpolation:{pattern:n(`((?:^|[^{])(?:\\{\\{)*)<<0>>`,[t]),lookbehind:!0,inside:{"format-string":{pattern:n(`(^\\{(?:(?![}:])<<0>>)*)<<1>>(?=\\}$)`,[r,D]),lookbehind:!0,inside:{punctuation:/^:/}},punctuation:/^\{|\}$/,expression:{pattern:/[\s\S]+/,alias:`language-csharp`,inside:e.languages.csharp}}},string:/[\s\S]+/}}e.languages.insertBefore(`csharp`,`string`,{"interpolation-string":[{pattern:n(`(^|[^\\\\])(?:\\$@|@\\$)"(?:""|\\\\[\\s\\S]|\\{\\{|<<0>>|[^\\\\{"])*"`,[O]),lookbehind:!0,greedy:!0,inside:ne(O,ee)},{pattern:n(`(^|[^@\\\\])\\$"(?:\\\\.|\\{\\{|<<0>>|[^\\\\"{])*"`,[te]),lookbehind:!0,greedy:!0,inside:ne(te,k)}],char:{pattern:RegExp(y),greedy:!0}}),e.languages.dotnet=e.languages.cs=e.languages.csharp})(Prism),(function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record(?!\s*[(){}[\]<>=%~.:,;?+\-*/&|^])|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,n=`(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*`,r={pattern:RegExp(`(^|[^\\w.])`+n+`[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b`),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}},punctuation:/\./}};e.languages.java=e.languages.extend(`clike`,{string:{pattern:/(^|[^\\])"(?:\\.|[^"\\\r\n])*"/,lookbehind:!0,greedy:!0},"class-name":[r,{pattern:RegExp(`(^|[^\\w.])`+n+`[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()]|\\s*(?:\\[[\\s,]*\\]\\s*)?::\\s*new\\b)`),lookbehind:!0,inside:r.inside},{pattern:RegExp(`(\\b(?:class|enum|extends|implements|instanceof|interface|new|record|throws)\\s+)`+n+`[A-Z]\\w*\\b`),lookbehind:!0,inside:r.inside}],keyword:t,function:[e.languages.clike.function,{pattern:/(::\s*)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0},constant:/\b[A-Z][A-Z_\d]+\b/}),e.languages.insertBefore(`java`,`string`,{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:`string`},char:{pattern:/'(?:\\.|[^'\\\r\n]){1,6}'/,greedy:!0}}),e.languages.insertBefore(`java`,`class-name`,{annotation:{pattern:/(^|[^.])@\w+(?:\s*\.\s*\w+)*/,lookbehind:!0,alias:`punctuation`},generics:{pattern:/<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/,inside:{"class-name":r,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}},import:[{pattern:RegExp(`(\\bimport\\s+)`+n+`(?:[A-Z]\\w*|\\*)(?=\\s*;)`),lookbehind:!0,inside:{namespace:r.inside.namespace,punctuation:/\./,operator:/\*/,"class-name":/\w+/}},{pattern:RegExp(`(\\bimport\\s+static\\s+)`+n+`(?:\\w+|\\*)(?=\\s*;)`),lookbehind:!0,alias:`static`,inside:{namespace:r.inside.namespace,static:/\b\w+$/,punctuation:/\./,operator:/\*/,"class-name":/\w+/}}],namespace:{pattern:RegExp(`(\\b(?:exports|import(?:\\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\\s+)(?!<keyword>)[a-z]\\w*(?:\\.[a-z]\\w*)*\\.?`.replace(/<keyword>/g,function(){return t.source})),lookbehind:!0,inside:{punctuation:/\./}}})})(Prism),Prism.languages.scala=Prism.languages.extend(`java`,{"triple-quoted-string":{pattern:/"""[\s\S]*?"""/,greedy:!0,alias:`string`},string:{pattern:/("|')(?:\\.|(?!\1)[^\\\r\n])*\1/,greedy:!0},keyword:/<-|=>|\b(?:abstract|case|catch|class|def|derives|do|else|enum|extends|extension|final|finally|for|forSome|given|if|implicit|import|infix|inline|lazy|match|new|null|object|opaque|open|override|package|private|protected|return|sealed|self|super|this|throw|trait|transparent|try|type|using|val|var|while|with|yield)\b/,number:/\b0x(?:[\da-f]*\.)?[\da-f]+|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e\d+)?[dfl]?/i,builtin:/\b(?:Any|AnyRef|AnyVal|Boolean|Byte|Char|Double|Float|Int|Long|Nothing|Short|String|Unit)\b/,symbol:/'[^\d\s\\]\w*/}),Prism.languages.insertBefore(`scala`,`triple-quoted-string`,{"string-interpolation":{pattern:/\b[a-z]\w*(?:"""(?:[^$]|\$(?:[^{]|\{(?:[^{}]|\{[^{}]*\})*\}))*?"""|"(?:[^$"\r\n]|\$(?:[^{]|\{(?:[^{}]|\{[^{}]*\})*\}))*")/i,greedy:!0,inside:{id:{pattern:/^\w+/,greedy:!0,alias:`function`},escape:{pattern:/\\\$"|\$[$"]/,greedy:!0,alias:`symbol`},interpolation:{pattern:/\$(?:\w+|\{(?:[^{}]|\{[^{}]*\})*\})/,greedy:!0,inside:{punctuation:/^\$\{?|\}$/,expression:{pattern:/[\s\S]+/,inside:Prism.languages.scala}}},string:/[\s\S]+/}}}),delete Prism.languages.scala[`class-name`],delete Prism.languages.scala.function,delete Prism.languages.scala.constant,(function(e){var t=`(?:\\r?\\n|\\r)[ \\t]*\\|.+\\|(?:(?!\\|).)*`;e.languages.gherkin={pystring:{pattern:/("""|''')[\s\S]+?\1/,alias:`string`},comment:{pattern:/(^[ \t]*)#.*/m,lookbehind:!0},tag:{pattern:/(^[ \t]*)@\S*/m,lookbehind:!0},feature:{pattern:/((?:^|\r?\n|\r)[ \t]*)(?:Ability|Ahoy matey!|Arwedd|Aspekt|Besigheid Behoefte|Business Need|Caracteristica|Característica|Egenskab|Egenskap|Eiginleiki|Feature|Fīča|Fitur|Fonctionnalité|Fonksyonalite|Funcionalidade|Funcionalitat|Functionalitate|Funcţionalitate|Funcționalitate|Functionaliteit|Fungsi|Funkcia|Funkcija|Funkcionalitāte|Funkcionalnost|Funkcja|Funksie|Funktionalität|Funktionalitéit|Funzionalità|Hwaet|Hwæt|Jellemző|Karakteristik|Lastnost|Mak|Mogucnost|laH|Mogućnost|Moznosti|Možnosti|OH HAI|Omadus|Ominaisuus|Osobina|Özellik|Potrzeba biznesowa|perbogh|poQbogh malja'|Požadavek|Požiadavka|Pretty much|Qap|Qu'meH 'ut|Savybė|Tính năng|Trajto|Vermoë|Vlastnosť|Właściwość|Značilnost|Δυνατότητα|Λειτουργία|Могућност|Мөмкинлек|Особина|Свойство|Үзенчәлеклелек|Функционал|Функционалност|Функция|Функціонал|תכונה|خاصية|خصوصیت|صلاحیت|کاروبار کی ضرورت|وِیژگی|रूप लेख|ਖਾਸੀਅਤ|ਨਕਸ਼ ਨੁਹਾਰ|ਮੁਹਾਂਦਰਾ|గుణము|ಹೆಚ್ಚಳ|ความต้องการทางธุรกิจ|ความสามารถ|โครงหลัก|기능|フィーチャ|功能|機能):(?:[^:\r\n]+(?:\r?\n|\r|$))*/,lookbehind:!0,inside:{important:{pattern:/(:)[^\r\n]+/,lookbehind:!0},keyword:/[^:\r\n]+:/}},scenario:{pattern:/(^[ \t]*)(?:Abstract Scenario|Abstrakt Scenario|Achtergrond|Aer|Ær|Agtergrond|All y'all|Antecedentes|Antecedents|Atburðarás|Atburðarásir|Awww, look mate|B4|Background|Baggrund|Bakgrund|Bakgrunn|Bakgrunnur|Beispiele|Beispiller|Bối cảnh|Cefndir|Cenario|Cenário|Cenario de Fundo|Cenário de Fundo|Cenarios|Cenários|Contesto|Context|Contexte|Contexto|Conto|Contoh|Contone|Dæmi|Dasar|Dead men tell no tales|Delineacao do Cenario|Delineação do Cenário|Dis is what went down|Dữ liệu|Dyagram Senaryo|Dyagram senaryo|Egzanp|Ejemplos|Eksempler|Ekzemploj|Enghreifftiau|Esbozo do escenario|Escenari|Escenario|Esempi|Esquema de l'escenari|Esquema del escenario|Esquema do Cenario|Esquema do Cenário|EXAMPLZ|Examples|Exempel|Exemple|Exemples|Exemplos|First off|Fono|Forgatókönyv|Forgatókönyv vázlat|Fundo|Geçmiş|Grundlage|Hannergrond|ghantoH|Háttér|Heave to|Istorik|Juhtumid|Keadaan|Khung kịch bản|Khung tình huống|Kịch bản|Koncept|Konsep skenario|Kontèks|Kontekst|Kontekstas|Konteksts|Kontext|Konturo de la scenaro|Latar Belakang|lut chovnatlh|lut|lutmey|Lýsing Atburðarásar|Lýsing Dæma|MISHUN SRSLY|MISHUN|Menggariskan Senario|mo'|Náčrt Scenára|Náčrt Scénáře|Náčrt Scenáru|Oris scenarija|Örnekler|Osnova|Osnova Scenára|Osnova scénáře|Osnutek|Ozadje|Paraugs|Pavyzdžiai|Példák|Piemēri|Plan du scénario|Plan du Scénario|Plan Senaryo|Plan senaryo|Plang vum Szenario|Pozadí|Pozadie|Pozadina|Príklady|Příklady|Primer|Primeri|Primjeri|Przykłady|Raamstsenaarium|Reckon it's like|Rerefons|Scenár|Scénář|Scenarie|Scenarij|Scenarijai|Scenarijaus šablonas|Scenariji|Scenārijs|Scenārijs pēc parauga|Scenarijus|Scenario|Scénario|Scenario Amlinellol|Scenario Outline|Scenario Template|Scenariomal|Scenariomall|Scenarios|Scenariu|Scenariusz|Scenaro|Schema dello scenario|Se ðe|Se the|Se þe|Senario|Senaryo Deskripsyon|Senaryo deskripsyon|Senaryo|Senaryo taslağı|Shiver me timbers|Situācija|Situai|Situasie Uiteensetting|Situasie|Skenario konsep|Skenario|Skica|Structura scenariu|Structură scenariu|Struktura scenarija|Stsenaarium|Swa hwaer swa|Swa|Swa hwær swa|Szablon scenariusza|Szenario|Szenariogrundriss|Tapaukset|Tapaus|Tapausaihio|Taust|Tausta|Template Keadaan|Template Senario|Template Situai|The thing of it is|Tình huống|Variantai|Voorbeelde|Voorbeelden|Wharrimean is|Yo-ho-ho|You'll wanna|Założenia|Παραδείγματα|Περιγραφή Σεναρίου|Σενάρια|Σενάριο|Υπόβαθρο|Кереш|Контекст|Концепт|Мисаллар|Мисоллар|Основа|Передумова|Позадина|Предистория|Предыстория|Приклади|Пример|Примери|Примеры|Рамка на сценарий|Скица|Структура сценарија|Структура сценария|Структура сценарію|Сценарий|Сценарий структураси|Сценарийның төзелеше|Сценарији|Сценарио|Сценарій|Тарих|Үрнәкләр|דוגמאות|רקע|תבנית תרחיש|תרחיש|الخلفية|الگوی سناریو|امثلة|پس منظر|زمینه|سناریو|سيناريو|سيناريو مخطط|مثالیں|منظر نامے کا خاکہ|منظرنامہ|نمونه ها|उदाहरण|परिदृश्य|परिदृश्य रूपरेखा|पृष्ठभूमि|ਉਦਾਹਰਨਾਂ|ਪਟਕਥਾ|ਪਟਕਥਾ ਢਾਂਚਾ|ਪਟਕਥਾ ਰੂਪ ਰੇਖਾ|ਪਿਛੋਕੜ|ఉదాహరణలు|కథనం|నేపథ్యం|సన్నివేశం|ಉದಾಹರಣೆಗಳು|ಕಥಾಸಾರಾಂಶ|ವಿವರಣೆ|ಹಿನ್ನೆಲೆ|โครงสร้างของเหตุการณ์|ชุดของตัวอย่าง|ชุดของเหตุการณ์|แนวคิด|สรุปเหตุการณ์|เหตุการณ์|배경|시나리오|시나리오 개요|예|サンプル|シナリオ|シナリオアウトライン|シナリオテンプレ|シナリオテンプレート|テンプレ|例|例子|剧本|剧本大纲|劇本|劇本大綱|场景|场景大纲|場景|場景大綱|背景):[^:\r\n]*/m,lookbehind:!0,inside:{important:{pattern:/(:)[^\r\n]*/,lookbehind:!0},keyword:/[^:\r\n]+:/}},"table-body":{pattern:RegExp(`(`+t+`)(?:`+t+`)+`),lookbehind:!0,inside:{outline:{pattern:/<[^>]+>/,alias:`variable`},td:{pattern:/\s*[^\s|][^|]*/,alias:`string`},punctuation:/\|/}},"table-head":{pattern:RegExp(t),inside:{th:{pattern:/\s*[^\s|][^|]*/,alias:`variable`},punctuation:/\|/}},atrule:{pattern:/(^[ \t]+)(?:'a|'ach|'ej|7|a|A také|A taktiež|A tiež|A zároveň|Aber|Ac|Adott|Akkor|Ak|Aleshores|Ale|Ali|Allora|Alors|Als|Ama|Amennyiben|Amikor|Ampak|an|AN|Ananging|And y'all|And|Angenommen|Anrhegedig a|An|Apabila|Atès|Atesa|Atunci|Avast!|Aye|A|awer|Bagi|Banjur|Bet|Biết|Blimey!|Buh|But at the end of the day I reckon|But y'all|But|BUT|Cal|Când|Cand|Cando|Ce|Cuando|Če|Ða ðe|Ða|Dadas|Dada|Dados|Dado|DaH ghu' bejlu'|dann|Dann|Dano|Dan|Dar|Dat fiind|Data|Date fiind|Date|Dati fiind|Dati|Daţi fiind|Dați fiind|DEN|Dato|De|Den youse gotta|Dengan|Diberi|Diyelim ki|Donada|Donat|Donitaĵo|Do|Dun|Duota|Ðurh|Eeldades|Ef|Eğer ki|Entao|Então|Entón|E|En|Entonces|Epi|És|Etant donnée|Etant donné|Et|Étant données|Étant donnée|Étant donné|Etant données|Etant donnés|Étant donnés|Fakat|Gangway!|Gdy|Gegeben seien|Gegeben sei|Gegeven|Gegewe|ghu' noblu'|Gitt|Given y'all|Given|Givet|Givun|Ha|Cho|I CAN HAZ|In|Ir|It's just unbelievable|I|Ja|Jeśli|Jeżeli|Kad|Kada|Kadar|Kai|Kaj|Když|Keď|Kemudian|Ketika|Khi|Kiedy|Ko|Kuid|Kui|Kun|Lan|latlh|Le sa a|Let go and haul|Le|Lè sa a|Lè|Logo|Lorsqu'<|Lorsque|mä|Maar|Mais|Mając|Ma|Majd|Maka|Manawa|Mas|Men|Menawa|Mutta|Nalika|Nalikaning|Nanging|Når|När|Nato|Nhưng|Niin|Njuk|O zaman|Och|Og|Oletetaan|Ond|Onda|Oraz|Pak|Pero|Però|Podano|Pokiaľ|Pokud|Potem|Potom|Privzeto|Pryd|Quan|Quand|Quando|qaSDI'|Så|Sed|Se|Siis|Sipoze ke|Sipoze Ke|Sipoze|Si|Şi|Și|Soit|Stel|Tada|Tad|Takrat|Tak|Tapi|Ter|Tetapi|Tha the|Tha|Then y'all|Then|Thì|Thurh|Toda|Too right|Un|Und|ugeholl|Và|vaj|Vendar|Ve|wann|Wanneer|WEN|Wenn|When y'all|When|Wtedy|Wun|Y'know|Yeah nah|Yna|Youse know like when|Youse know when youse got|Y|Za predpokladu|Za předpokladu|Zadan|Zadani|Zadano|Zadate|Zadato|Zakładając|Zaradi|Zatati|Þa þe|Þa|Þá|Þegar|Þurh|Αλλά|Δεδομένου|Και|Όταν|Τότε|А також|Агар|Але|Али|Аммо|А|Әгәр|Әйтик|Әмма|Бирок|Ва|Вә|Дадено|Дано|Допустим|Если|Задате|Задати|Задато|И|І|К тому же|Када|Кад|Когато|Когда|Коли|Ләкин|Лекин|Нәтиҗәдә|Нехай|Но|Онда|Припустимо, що|Припустимо|Пусть|Также|Та|Тогда|Тоді|То|Унда|Һәм|Якщо|אבל|אזי|אז|בהינתן|וגם|כאשר|آنگاه|اذاً|اگر|اما|اور|با فرض|بالفرض|بفرض|پھر|تب|ثم|جب|عندما|فرض کیا|لكن|لیکن|متى|هنگامی|و|अगर|और|कदा|किन्तु|चूंकि|जब|तथा|तदा|तब|परन्तु|पर|यदि|ਅਤੇ|ਜਦੋਂ|ਜਿਵੇਂ ਕਿ|ਜੇਕਰ|ਤਦ|ਪਰ|అప్పుడు|ఈ పరిస్థితిలో|కాని|చెప్పబడినది|మరియు|ಆದರೆ|ನಂತರ|ನೀಡಿದ|ಮತ್ತು|ಸ್ಥಿತಿಯನ್ನು|กำหนดให้|ดังนั้น|แต่|เมื่อ|และ|그러면<|그리고<|단<|만약<|만일<|먼저<|조건<|하지만<|かつ<|しかし<|ただし<|ならば<|もし<|並且<|但し<|但是<|假如<|假定<|假設<|假设<|前提<|同时<|同時<|并且<|当<|當<|而且<|那么<|那麼<)(?=[ \t])/m,lookbehind:!0},string:{pattern:/"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'/,inside:{outline:{pattern:/<[^>]+>/,alias:`variable`}}},outline:{pattern:/<[^>]+>/,alias:`variable`}}})(Prism),(function(e){for(var t=`\\/\\*(?:[^*/]|\\*(?!\\/)|\\/(?!\\*)|<self>)*\\*\\/`,n=0;n<2;n++)t=t.replace(/<self>/g,function(){return t});t=t.replace(/<self>/g,function(){return`[^\\s\\S]`}),e.languages.rust={comment:[{pattern:RegExp(`(^|[^\\\\])`+t),lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/b?"(?:\\[\s\S]|[^\\"])*"|b?r(#*)"(?:[^"]|"(?!\1))*"\1/,greedy:!0},char:{pattern:/b?'(?:\\(?:x[0-7][\da-fA-F]|u\{(?:[\da-fA-F]_*){1,6}\}|.)|[^\\\r\n\t'])'/,greedy:!0},attribute:{pattern:/#!?\[(?:[^\[\]"]|"(?:\\[\s\S]|[^\\"])*")*\]/,greedy:!0,alias:`attr-name`,inside:{string:null}},"closure-params":{pattern:/([=(,:]\s*|\bmove\s*)\|[^|]*\||\|[^|]*\|(?=\s*(?:\{|->))/,lookbehind:!0,greedy:!0,inside:{"closure-punctuation":{pattern:/^\||\|$/,alias:`punctuation`},rest:null}},"lifetime-annotation":{pattern:/'\w+/,alias:`symbol`},"fragment-specifier":{pattern:/(\$\w+:)[a-z]+/,lookbehind:!0,alias:`punctuation`},variable:/\$\w+/,"function-definition":{pattern:/(\bfn\s+)\w+/,lookbehind:!0,alias:`function`},"type-definition":{pattern:/(\b(?:enum|struct|trait|type|union)\s+)\w+/,lookbehind:!0,alias:`class-name`},"module-declaration":[{pattern:/(\b(?:crate|mod)\s+)[a-z][a-z_\d]*/,lookbehind:!0,alias:`namespace`},{pattern:/(\b(?:crate|self|super)\s*)::\s*[a-z][a-z_\d]*\b(?:\s*::(?:\s*[a-z][a-z_\d]*\s*::)*)?/,lookbehind:!0,alias:`namespace`,inside:{punctuation:/::/}}],keyword:[/\b(?:Self|abstract|as|async|await|become|box|break|const|continue|crate|do|dyn|else|enum|extern|final|fn|for|if|impl|in|let|loop|macro|match|mod|move|mut|override|priv|pub|ref|return|self|static|struct|super|trait|try|type|typeof|union|unsafe|unsized|use|virtual|where|while|yield)\b/,/\b(?:bool|char|f(?:32|64)|[ui](?:8|16|32|64|128|size)|str)\b/],function:/\b[a-z_]\w*(?=\s*(?:::\s*<|\())/,macro:{pattern:/\b\w+!/,alias:`property`},constant:/\b[A-Z_][A-Z_\d]+\b/,"class-name":/\b[A-Z]\w*\b/,namespace:{pattern:/(?:\b[a-z][a-z_\d]*\s*::\s*)*\b[a-z][a-z_\d]*\s*::(?!\s*<)/,inside:{punctuation:/::/}},number:/\b(?:0x[\dA-Fa-f](?:_?[\dA-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|(?:(?:\d(?:_?\d)*)?\.)?\d(?:_?\d)*(?:[Ee][+-]?\d+)?)(?:_?(?:f32|f64|[iu](?:8|16|32|64|size)?))?\b/,boolean:/\b(?:false|true)\b/,punctuation:/->|\.\.=|\.{1,3}|::|[{}[\];(),:]/,operator:/[-+*\/%!^]=?|=[=>]?|&[&=]?|\|[|=]?|<<?=?|>>?=?|[@?]/},e.languages.rust[`closure-params`].inside.rest=e.languages.rust,e.languages.rust.attribute.inside.string=e.languages.rust.string})(Prism),Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:`punctuation`},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:`string`},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:[`annotation`,`punctuation`],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python[`string-interpolation`].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python,Prism.languages.markup={comment:{pattern:/<!--(?:(?!<!--)[\s\S])*?-->/,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/<!DOCTYPE(?:[^>"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|<!--(?:[^-]|-(?!->))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^<!|>$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:`attr-equals`},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:`named-entity`},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside[`attr-value`].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside[`internal-subset`].inside=Prism.languages.markup,Prism.hooks.add(`wrap`,function(e){e.type===`entity`&&(e.attributes.title=e.content.replace(/&amp;/,`&`))}),Object.defineProperty(Prism.languages.markup.tag,`addInlined`,{value:function(e,t){var n={};n[`language-`+t]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[t]},n.cdata=/^<!\[CDATA\[|\]\]>$/i;var r={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:n}};r[`language-`+t]={pattern:/[\s\S]+/,inside:Prism.languages[t]};var i={};i[e]={pattern:RegExp(`(<__[^>]*>)(?:<!\\[CDATA\\[(?:[^\\]]|\\](?!\\]>))*\\]\\]>|(?!<!\\[CDATA\\[)[\\s\\S])*?(?=<\\/__>)`.replace(/__/g,function(){return e}),`i`),lookbehind:!0,greedy:!0,inside:r},Prism.languages.insertBefore(`markup`,`cdata`,i)}}),Object.defineProperty(Prism.languages.markup.tag,`addAttribute`,{value:function(e,t){Prism.languages.markup.tag.inside[`special-attr`].push({pattern:RegExp(`(^|["'\\s])(?:`+e+`)\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s'">=]+(?=[\\s>]))`,`i`),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[t,`language-`+t],inside:Prism.languages[t]},punctuation:[{pattern:/^=/,alias:`attr-equals`},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend(`markup`,{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml,(function(e){function t(e,t){return`___`+e.toUpperCase()+t+`___`}Object.defineProperties(e.languages[`markup-templating`]={},{buildPlaceholders:{value:function(n,r,i,a){if(n.language===r){var o=n.tokenStack=[];n.code=n.code.replace(i,function(e){if(typeof a==`function`&&!a(e))return e;for(var i=o.length,s;n.code.indexOf(s=t(r,i))!==-1;)++i;return o[i]=e,s}),n.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(n,r){if(n.language!==r||!n.tokenStack)return;n.grammar=e.languages[r];var i=0,a=Object.keys(n.tokenStack);function o(s){for(var c=0;c<s.length&&!(i>=a.length);c++){var l=s[c];if(typeof l==`string`||l.content&&typeof l.content==`string`){var u=a[i],d=n.tokenStack[u],f=typeof l==`string`?l:l.content,p=t(r,u),m=f.indexOf(p);if(m>-1){++i;var h=f.substring(0,m),g=new e.Token(r,e.tokenize(d,n.grammar),`language-`+r,d),_=f.substring(m+p.length),v=[];h&&v.push.apply(v,o([h])),v.push(g),_&&v.push.apply(v,o([_])),typeof l==`string`?s.splice.apply(s,[c,1].concat(v)):l.content=v}}else l.content&&o(l.content)}return s}o(n.tokens)}}})})(Prism),(function(e){var t=/\/\*[\s\S]*?\*\/|\/\/.*|#(?!\[).*/,n=[{pattern:/\b(?:false|true)\b/i,alias:`boolean`},{pattern:/(::\s*)\b[a-z_]\w*\b(?!\s*\()/i,greedy:!0,lookbehind:!0},{pattern:/(\b(?:case|const)\s+)\b[a-z_]\w*(?=\s*[;=])/i,greedy:!0,lookbehind:!0},/\b(?:null)\b/i,/\b[A-Z_][A-Z0-9_]*\b(?!\s*\()/],r=/\b0b[01]+(?:_[01]+)*\b|\b0o[0-7]+(?:_[0-7]+)*\b|\b0x[\da-f]+(?:_[\da-f]+)*\b|(?:\b\d+(?:_\d+)*\.?(?:\d+(?:_\d+)*)?|\B\.\d+)(?:e[+-]?\d+)?/i,i=/<?=>|\?\?=?|\.{3}|\??->|[!=]=?=?|::|\*\*=?|--|\+\+|&&|\|\||<<|>>|[?~]|[/^|%*&<>.+-]=?/,a=/[{}\[\](),:;]/;e.languages.php={delimiter:{pattern:/\?>$|^<\?(?:php(?=\s)|=)?/i,alias:`important`},comment:t,variable:/\$+(?:\w+\b|(?=\{))/,package:{pattern:/(namespace\s+|use\s+(?:function\s+)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,lookbehind:!0,inside:{punctuation:/\\/}},"class-name-definition":{pattern:/(\b(?:class|enum|interface|trait)\s+)\b[a-z_]\w*(?!\\)\b/i,lookbehind:!0,alias:`class-name`},"function-definition":{pattern:/(\bfunction\s+)[a-z_]\w*(?=\s*\()/i,lookbehind:!0,alias:`function`},keyword:[{pattern:/(\(\s*)\b(?:array|bool|boolean|float|int|integer|object|string)\b(?=\s*\))/i,alias:`type-casting`,greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|object|self|static|string)\b(?=\s*\$)/i,alias:`type-hint`,greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|never|object|self|static|string|void)\b/i,alias:`return-type`,greedy:!0,lookbehind:!0},{pattern:/\b(?:array(?!\s*\()|bool|float|int|iterable|mixed|object|string|void)\b/i,alias:`type-declaration`,greedy:!0},{pattern:/(\|\s*)(?:false|null)\b|\b(?:false|null)(?=\s*\|)/i,alias:`type-declaration`,greedy:!0,lookbehind:!0},{pattern:/\b(?:parent|self|static)(?=\s*::)/i,alias:`static-context`,greedy:!0},{pattern:/(\byield\s+)from\b/i,lookbehind:!0},/\bclass\b/i,{pattern:/((?:^|[^\s>:]|(?:^|[^-])>|(?:^|[^:]):)\s*)\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\b/i,lookbehind:!0}],"argument-name":{pattern:/([(,]\s*)\b[a-z_]\w*(?=\s*:(?!:))/i,lookbehind:!0},"class-name":[{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self|\s+static))\s+|\bcatch\s*\()\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/(\|\s*)\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/\b[a-z_]\w*(?!\\)\b(?=\s*\|)/i,greedy:!0},{pattern:/(\|\s*)(?:\\?\b[a-z_]\w*)+\b/i,alias:`class-name-fully-qualified`,greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(?:\\?\b[a-z_]\w*)+\b(?=\s*\|)/i,alias:`class-name-fully-qualified`,greedy:!0,inside:{punctuation:/\\/}},{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self\b|\s+static\b))\s+|\bcatch\s*\()(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:`class-name-fully-qualified`,greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*\$)/i,alias:`type-declaration`,greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:[`class-name-fully-qualified`,`type-declaration`],greedy:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*::)/i,alias:`static-context`,greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*::)/i,alias:[`class-name-fully-qualified`,`static-context`],greedy:!0,inside:{punctuation:/\\/}},{pattern:/([(,?]\s*)[a-z_]\w*(?=\s*\$)/i,alias:`type-hint`,greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:[`class-name-fully-qualified`,`type-hint`],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b[a-z_]\w*(?!\\)\b/i,alias:`return-type`,greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:[`class-name-fully-qualified`,`return-type`],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:n,function:{pattern:/(^|[^\\\w])\\?[a-z_](?:[\w\\]*\w)?(?=\s*\()/i,lookbehind:!0,inside:{punctuation:/\\/}},property:{pattern:/(->\s*)\w+/,lookbehind:!0},number:r,operator:i,punctuation:a};var o={pattern:/\{\$(?:\{(?:\{[^{}]+\}|[^{}]+)\}|[^{}])+\}|(^|[^\\{])\$+(?:\w+(?:\[[^\r\n\[\]]+\]|->\w+)?)/,lookbehind:!0,inside:e.languages.php},s=[{pattern:/<<<'([^']+)'[\r\n](?:.*[\r\n])*?\1;/,alias:`nowdoc-string`,greedy:!0,inside:{delimiter:{pattern:/^<<<'[^']+'|[a-z_]\w*;$/i,alias:`symbol`,inside:{punctuation:/^<<<'?|[';]$/}}}},{pattern:/<<<(?:"([^"]+)"[\r\n](?:.*[\r\n])*?\1;|([a-z_]\w*)[\r\n](?:.*[\r\n])*?\2;)/i,alias:`heredoc-string`,greedy:!0,inside:{delimiter:{pattern:/^<<<(?:"[^"]+"|[a-z_]\w*)|[a-z_]\w*;$/i,alias:`symbol`,inside:{punctuation:/^<<<"?|[";]$/}},interpolation:o}},{pattern:/`(?:\\[\s\S]|[^\\`])*`/,alias:`backtick-quoted-string`,greedy:!0},{pattern:/'(?:\\[\s\S]|[^\\'])*'/,alias:`single-quoted-string`,greedy:!0},{pattern:/"(?:\\[\s\S]|[^\\"])*"/,alias:`double-quoted-string`,greedy:!0,inside:{interpolation:o}}];e.languages.insertBefore(`php`,`variable`,{string:s,attribute:{pattern:/#\[(?:[^"'\/#]|\/(?![*/])|\/\/.*$|#(?!\[).*$|\/\*(?:[^*]|\*(?!\/))*\*\/|"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*')+\](?=\s*[a-z$#])/im,greedy:!0,inside:{"attribute-content":{pattern:/^(#\[)[\s\S]+(?=\]$)/,lookbehind:!0,inside:{comment:t,string:s,"attribute-class-name":[{pattern:/([^:]|^)\b[a-z_]\w*(?!\\)\b/i,alias:`class-name`,greedy:!0,lookbehind:!0},{pattern:/([^:]|^)(?:\\?\b[a-z_]\w*)+/i,alias:[`class-name`,`class-name-fully-qualified`],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:n,number:r,operator:i,punctuation:a}},delimiter:{pattern:/^#\[|\]$/,alias:`punctuation`}}}}),e.hooks.add(`before-tokenize`,function(t){/<\?/.test(t.code)&&e.languages[`markup-templating`].buildPlaceholders(t,`php`,/<\?(?:[^"'/#]|\/(?![*/])|("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|(?:\/\/|#(?!\[))(?:[^?\n\r]|\?(?!>))*(?=$|\?>|[\r\n])|#\[|\/\*(?:[^*]|\*(?!\/))*(?:\*\/|$))*?(?:\?>|$)/g)}),e.hooks.add(`after-tokenize`,function(t){e.languages[`markup-templating`].tokenizePlaceholders(t,`php`)})})(Prism);var Zr=`(if|else if|await|then|catch|each|html|debug)`;Prism.languages.svelte=Prism.languages.extend(`markup`,{each:{pattern:RegExp(`{[#/]each(?:(?:\\{(?:(?:\\{(?:[^{}])*\\})|(?:[^{}]))*\\})|(?:[^{}]))*}`),inside:{"language-javascript":[{pattern:/(as[\s\S]*)\([\s\S]*\)(?=\s*\})/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(as[\s]*)[\s\S]*(?=\s*)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(#each[\s]*)[\s\S]*(?=as)/,lookbehind:!0,inside:Prism.languages.javascript}],keyword:/[#/]each|as/,punctuation:/{|}/}},block:{pattern:RegExp(`{[#:/@]/s`+Zr+`(?:(?:\\{(?:(?:\\{(?:[^{}])*\\})|(?:[^{}]))*\\})|(?:[^{}]))*}`),inside:{punctuation:/^{|}$/,keyword:[RegExp(`[#:/@]`+Zr+`( )*`),/as/,/then/],"language-javascript":{pattern:/[\s\S]*/,inside:Prism.languages.javascript}}},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?:"[^"]*"|'[^']*'|{[\s\S]+?}(?=[\s/>])))|(?=[\s/>])))+)?\s*\/?>/i,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"language-javascript":{pattern:/\{(?:(?:\{(?:(?:\{(?:[^{}])*\})|(?:[^{}]))*\})|(?:[^{}]))*\}/,inside:Prism.languages.javascript},"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/i,inside:{punctuation:[/^=/,{pattern:/^(\s*)["']|["']$/,lookbehind:!0}],"language-javascript":{pattern:/{[\s\S]+}/,inside:Prism.languages.javascript}}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},"language-javascript":{pattern:/\{(?:(?:\{(?:(?:\{(?:[^{}])*\})|(?:[^{}]))*\})|(?:[^{}]))*\}/,lookbehind:!0,inside:Prism.languages.javascript}}),Prism.languages.svelte.tag.inside[`attr-value`].inside.entity=Prism.languages.svelte.entity,Prism.hooks.add(`wrap`,e=>{e.type===`entity`&&(e.attributes.title=e.content.replace(/&amp;/,`&`))}),Object.defineProperty(Prism.languages.svelte.tag,`addInlined`,{value:function(e,t){let n={};n[`language-`+t]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[t]},n.cdata=/^<!\[CDATA\[|\]\]>$/i;let r={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:n}};r[`language-`+t]={pattern:/[\s\S]+/,inside:Prism.languages[t]};let i={};i[e]={pattern:RegExp(`(<__[\\s\\S]*?>)(?:<!\\[CDATA\\[[\\s\\S]*?\\]\\]>\\s*|[\\s\\S])*?(?=<\\/__>)`.replace(/__/g,e),`i`),lookbehind:!0,greedy:!0,inside:r},Prism.languages.insertBefore(`svelte`,`cdata`,i)}}),Prism.languages.svelte.tag.addInlined(`style`,`css`),Prism.languages.svelte.tag.addInlined(`script`,`javascript`),(function(){typeof Prism>`u`||typeof document>`u`||!document.createRange||(Prism.plugins.KeepMarkup=!0,Prism.hooks.add(`before-highlight`,function(e){if(!e.element.children.length||!Prism.util.isActive(e.element,`keep-markup`,!0))return;var t=Prism.util.isActive(e.element,`drop-tokens`,!1);function n(e){return!(t&&e.nodeName.toLowerCase()===`span`&&e.classList.contains(`token`))}var r=0,i=[];function a(e){if(!n(e)){o(e);return}var t={element:e,posOpen:r};i.push(t),o(e),t.posClose=r}function o(e){for(var t=0,n=e.childNodes.length;t<n;t++){var i=e.childNodes[t];i.nodeType===1?a(i):i.nodeType===3&&(r+=i.data.length)}}o(e.element),i.length&&(e.keepMarkup=i)}),Prism.hooks.add(`after-highlight`,function(e){if(e.keepMarkup&&e.keepMarkup.length){var t=function(e,n){for(var r=0,i=e.childNodes.length;r<i;r++){var a=e.childNodes[r];if(a.nodeType===1){if(!t(a,n))return!1}else a.nodeType===3&&(!n.nodeStart&&n.pos+a.data.length>n.node.posOpen&&(n.nodeStart=a,n.nodeStartPos=n.node.posOpen-n.pos),n.nodeStart&&n.pos+a.data.length>=n.node.posClose&&(n.nodeEnd=a,n.nodeEndPos=n.node.posClose-n.pos),n.pos+=a.data.length);if(n.nodeStart&&n.nodeEnd){var o=document.createRange();return o.setStart(n.nodeStart,n.nodeStartPos),o.setEnd(n.nodeEnd,n.nodeEndPos),n.node.element.innerHTML=``,n.node.element.appendChild(o.extractContents()),o.insertNode(n.node.element),o.detach(),!1}}return!0};e.keepMarkup.forEach(function(n){t(e.element,{node:n,pos:0})}),e.highlightedCode=e.element.innerHTML}}))})();var Qr=`code[class*=language-],pre[class*=language-]{color:var(--prism-maintext);text-align:left;white-space:pre;word-spacing:normal;word-break:normal;tab-size:4;-webkit-hyphens:none;hyphens:none;direction:ltr;font-size:1em;line-height:1.5}pre>code[class*=language-]{font-size:1em}pre[class*=language-]{border:1px solid var(--prism-border);border-radius:.25rem;margin:.5em 0;padding:1em;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:var(--prism-background)}.token.comment,.token.prolog,.token.doctype,.token.italic,.token.cdata{font-style:italic}.token.important,.token.function,.token.bold{font-weight:700}.token.namespace{opacity:.7}.token.atrule{color:var(--prism-atrule)}.token.attr{color:var(--prism-attr)}.token.attr-name{color:var(--prism-attr-name)}.token.boolean{color:var(--prism-boolean)}.token.builtin{color:var(--prism-builtin)}.token.cdata{color:var(--prism-cdata)}.token.changed{color:var(--prism-changed)}.token.char{color:var(--prism-char)}.token.comment{color:var(--prism-comment)}.token.constant{color:var(--prism-constant)}.token.deleted{color:var(--prism-deleted)}.token.doctype{color:var(--prism-doctype)}.token.entity{color:var(--prism-entity);cursor:help}.token.function{color:var(--prism-function)}.token.function-variable{color:var(--prism-function-variable,var(--prism-function))}.token.inserted{color:var(--prism-inserted)}.token.keyword{color:var(--prism-keyword)}.token.number{color:var(--prism-number)}.token.operator{color:var(--prism-operator)}.token.prolog{color:var(--prism-prolog)}.token.property{color:var(--prism-property)}.token.punctuation{color:var(--prism-punctuation)}.token.regex{color:var(--prism-regex)}.token.selector{color:var(--prism-selector)}.token.string{color:var(--prism-string)}.token.symbol{color:var(--prism-symbol)}.token.tag{color:var(--prism-tag)}.token.url{color:var(--prism-url)}.token.variable{color:var(--prism-variable)}.token.placeholder{color:var(--prism-placeholder)}.token.statement{color:var(--prism-statement)}.token.attr-value{color:var(--prism-attr-value)}.token.control{color:var(--prism-control)}.token.directive{color:var(--prism-directive)}.token.unit{color:var(--prism-unit)}.token.important{color:var(--prism-important)}.token.class-name{color:var(--prism-class-name)}`,U=s(`/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
10
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-content:"";--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-800:oklch(47% .157 37.304);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-100:oklch(96.2% .044 156.743);--color-green-300:oklch(87.1% .15 154.449);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-cyan-600:oklch(60.9% .126 221.723);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-gray-50:var(--mut-gray-50,var(--color-zinc-50));--color-gray-200:var(--mut-gray-200,var(--color-zinc-200));--color-gray-400:var(--mut-gray-400,var(--color-zinc-400));--color-gray-600:var(--mut-gray-600,var(--color-zinc-600));--color-gray-700:var(--mut-gray-700,var(--color-zinc-700));--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-neutral-400:oklch(70.8% 0 0);--color-white:var(--mut-white,#fff);--spacing:.25rem;--container-6xl:72rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-3xl:1.5rem;--blur-lg:16px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--transition-property-max-width:max-width;--transition-property-width:width;--transition-property-stroke-opacity:stroke-opacity;--spacing-drawer-half-open:var(--mte-drawer-height-half-open,120px);--color-primary-500:var(--mut-primary-500,var(--color-sky-500));--color-primary-600:var(--mut-primary-600,var(--color-sky-600))}:host{--mte-drawer-height-half-open:120px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select{appearance:none;border-color:var(--mut-gray-500,var(--color-zinc-500));--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is(input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:var(--mut-gray-500,var(--color-zinc-500));opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em}::-webkit-date-and-time-value{text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}select:where([multiple]),select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}input:where([type=checkbox]),input:where([type=radio]){appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);border-color:var(--mut-gray-500,var(--color-zinc-500));--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}input:where([type=checkbox]):checked,input:where([type=radio]):checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=checkbox]):checked{appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=radio]):checked{appearance:auto}}input:where([type=checkbox]):checked:hover,input:where([type=checkbox]):checked:focus,input:where([type=radio]):checked:hover,input:where([type=radio]):checked:focus{background-color:currentColor;border-color:#0000}input:where([type=checkbox]):indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){input:where([type=checkbox]):indeterminate{appearance:auto}}input:where([type=checkbox]):indeterminate:hover,input:where([type=checkbox]):indeterminate:focus{background-color:currentColor;border-color:#0000}input:where([type=file]){background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}input:where([type=file]):focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.start\\!{inset-inline-start:var(--spacing)!important}.end{inset-inline-end:var(--spacing)}.end\\!{inset-inline-end:var(--spacing)!important}.top-offset{top:var(--top-offset,0)}.bottom-0{bottom:calc(var(--spacing) * 0)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-20{z-index:20}.float-right{float:right}.container{width:100%}@media (width>=2000px){.container{max-width:2000px}}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.container{margin-inline:auto}.mx-0\\.5{margin-inline:calc(var(--spacing) * .5)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.ms-1{margin-inline-start:calc(var(--spacing) * 1)}.ms-3{margin-inline-start:calc(var(--spacing) * 3)}.me-1{margin-inline-end:calc(var(--spacing) * 1)}.me-2{margin-inline-end:calc(var(--spacing) * 2)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:calc(var(--spacing) * 0)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-100{height:calc(var(--spacing) * 100)}.h-fit{height:fit-content}.h-full{height:100%}.max-h-132{max-height:calc(var(--spacing) * 132)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-12{width:calc(var(--spacing) * 12)}.w-24{width:calc(var(--spacing) * 24)}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-160{max-width:calc(var(--spacing) * 160)}.min-w-\\[24px\\]{min-width:24px}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.table-auto{table-layout:auto}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.resize{resize:both}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-start{scroll-snap-align:start}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-around{justify-content:space-around}.justify-center{justify-content:center}.justify-start{justify-content:flex-start}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--mut-gray-200,var(--color-zinc-200))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-t-3xl{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-none{--tw-border-style:none;border-style:none}.border-gray-200{border-color:var(--mut-gray-200,var(--color-zinc-200))}.border-transparent{border-color:#0000}.bg-cyan-600{background-color:var(--color-cyan-600)}.bg-gray-100{background-color:var(--mut-gray-100,var(--color-zinc-100))}.bg-gray-200{background-color:var(--mut-gray-200,var(--color-zinc-200))}.bg-gray-200\\/60{background-color:var(--mut-gray-200,oklch(92% .004 286.32))}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\\/60{background-color:color-mix(in oklab, var(--mut-gray-200,var(--color-zinc-200)) 60%, transparent)}}.bg-gray-300{background-color:var(--mut-gray-300,var(--color-zinc-300))}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-inherit{background-color:inherit}.bg-orange-100{background-color:var(--color-orange-100)}.bg-primary-100{background-color:var(--mut-primary-100,var(--color-sky-100))}.bg-primary-600{background-color:var(--mut-primary-600,var(--color-sky-600))}.bg-red-100{background-color:var(--color-red-100)}.bg-red-600{background-color:var(--color-red-600)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--mut-white,#fff)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-600{background-color:var(--color-yellow-600)}.stroke-gray-800{stroke:var(--mut-gray-800,var(--color-zinc-800))}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.pe-4{padding-inline-end:calc(var(--spacing) * 4)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-drawer-half-open{padding-bottom:var(--mte-drawer-height-half-open,120px)}.pl-1{padding-left:calc(var(--spacing) * 1)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-sans{font-family:var(--font-sans)}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-200{color:var(--mut-gray-200,var(--color-zinc-200))}.text-gray-400{color:var(--mut-gray-400,var(--color-zinc-400))}.text-gray-600{color:var(--mut-gray-600,var(--color-zinc-600))}.text-gray-700{color:var(--mut-gray-700,var(--color-zinc-700))}.text-gray-800{color:var(--mut-gray-800,var(--color-zinc-800))}.text-gray-900{color:var(--mut-gray-900,var(--color-zinc-900))}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-orange-800{color:var(--color-orange-800)}.text-primary-500{color:var(--mut-primary-500,var(--color-sky-500))}.text-primary-800{color:var(--mut-primary-800,var(--color-sky-800))}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--mut-white,#fff)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-offset-gray-200\\!{--tw-ring-offset-color:var(--mut-gray-200,var(--color-zinc-200))!important}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-lg{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-stroke-opacity{transition-property:var(--transition-property-stroke-opacity);transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\\[httparchive\\:summary_pages\\.2018_12_15_desktop\\]{httparchive:summary pages.2018 12 15 desktop}@media (hover:hover){.group-hover\\:bg-gray-200\\!:is(:where(.group):hover *){background-color:var(--mut-gray-200,var(--color-zinc-200))!important}}.group-aria-selected\\:text-gray-200:is(:where(.group)[aria-selected=true] *){color:var(--mut-gray-200,var(--color-zinc-200))}.group-aria-selected\\:text-primary-50:is(:where(.group)[aria-selected=true] *){color:var(--mut-primary-50,var(--color-sky-50))}.group-aria-selected\\:underline:is(:where(.group)[aria-selected=true] *){text-decoration-line:underline}.backdrop\\:bg-gray-950\\/50::backdrop{background-color:var(--mut-gray-950,oklch(14.1% .005 285.823))}@supports (color:color-mix(in lab, red, red)){.backdrop\\:bg-gray-950\\/50::backdrop{background-color:color-mix(in oklab, var(--mut-gray-950,var(--color-zinc-950)) 50%, transparent)}}.backdrop\\:backdrop-blur-lg::backdrop{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.after\\:text-gray-800:after{content:var(--tw-content);color:var(--mut-gray-800,var(--color-zinc-800))}.after\\:content-\\[\\'\\/\\'\\]:after{--tw-content:"/";content:var(--tw-content)}.last\\:mr-12:last-child{margin-right:calc(var(--spacing) * 12)}.odd\\:bg-gray-100:nth-child(odd),.even\\:bg-gray-100:nth-child(2n){background-color:var(--mut-gray-100,var(--color-zinc-100))}.checked\\:bg-primary-600:checked{background-color:var(--mut-primary-600,var(--color-sky-600))}@media (hover:hover){.hover\\:cursor-pointer:hover{cursor:pointer}.hover\\:border-gray-300:hover{border-color:var(--mut-gray-300,var(--color-zinc-300))}.hover\\:bg-gray-100:hover{background-color:var(--mut-gray-100,var(--color-zinc-100))}.hover\\:bg-gray-200:hover{background-color:var(--mut-gray-200,var(--color-zinc-200))}.hover\\:bg-primary-700:hover{background-color:var(--mut-primary-700,var(--color-sky-700))}.hover\\:text-gray-700:hover{color:var(--mut-gray-700,var(--color-zinc-700))}.hover\\:text-gray-900:hover{color:var(--mut-gray-900,var(--color-zinc-900))}.hover\\:text-primary-on:hover{color:var(--mut-primary-on,var(--color-sky-700))}.hover\\:underline:hover{text-decoration-line:underline}}.focus\\:shadow-none:focus{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\\:ring-primary-500:focus{--tw-ring-color:var(--mut-primary-500,var(--color-sky-500))}.focus\\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.active\\:bg-gray-200:active{background-color:var(--mut-gray-200,var(--color-zinc-200))}.aria-selected\\:border-b-\\[3px\\][aria-selected=true]{border-bottom-style:var(--tw-border-style);border-bottom-width:3px}.aria-selected\\:border-solid[aria-selected=true]{--tw-border-style:solid;border-style:solid}.aria-selected\\:border-primary-700[aria-selected=true]{border-color:var(--mut-primary-700,var(--color-sky-700))}.aria-selected\\:bg-primary-500[aria-selected=true]{background-color:var(--mut-primary-500,var(--color-sky-500))}.aria-selected\\:text-gray-50[aria-selected=true]{color:var(--mut-gray-50,var(--color-zinc-50))}.aria-selected\\:text-primary-on[aria-selected=true]{color:var(--mut-primary-on,var(--color-sky-700))}.aria-selected\\:shadow-lg[aria-selected=true]{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (prefers-reduced-motion:no-preference){.motion-safe\\:transition-\\[height\\,max-width\\]{transition-property:height,max-width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\\:transition-max-width{transition-property:var(--transition-property-max-width);transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\\:transition-width{transition-property:var(--transition-property-width);transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.motion-safe\\:duration-200{--tw-duration:.2s;transition-duration:.2s}}@media (width>=48rem){.md\\:ml-2{margin-left:calc(var(--spacing) * 2)}.md\\:w-1\\/2{width:50%}.md\\:after\\:pl-1:after{content:var(--tw-content);padding-left:calc(var(--spacing) * 1)}}@media (width>=96rem){.\\32 xl\\:w-28{width:calc(var(--spacing) * 28)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-duration{syntax:"*";inherits:false}`),$r=s(Qr);if(U.styleSheet&&document?.adoptedStyleSheets&&!document.adoptedStyleSheets.some(e=>e.cssRules[0]?.cssText===U.styleSheet.cssRules[0].cssText)){let e=new CSSStyleSheet,t=U.cssText;t=t.replaceAll(`inherits: false`,`inherits: true`).substring(t.indexOf(`@property`)),e.replaceSync(t),document.adoptedStyleSheets.push(e)}var ei=class extends Ne{static{this.styles=[U]}},ti=(e,t)=>j`<li title=${e.trim()||P} class="my-3 rounded-sm bg-white px-2 py-3 shadow-sm">${t}</li>`,ni=(e,t)=>j`<p title=${t?.trim()||P}>${e}</p>`,ri=e=>j`<div class="mt-2 mr-6 mb-6 flex flex-col gap-4">${e}</div>`,W=(e,t)=>j`<span role="img" aria-label=${t}>${e}</span>`,ii=`:host([mode=closed]){height:0}:host([mode=half]){height:var(--spacing-drawer-half-open)}:host([mode=open]){height:50%}`;function G(e,t,n,r){var i=arguments.length,a=i<3?t:r===null?r=Object.getOwnPropertyDescriptor(t,n):r,o;if(typeof Reflect==`object`&&typeof Reflect.decorate==`function`)a=Reflect.decorate(e,t,n,r);else for(var s=e.length-1;s>=0;s--)(o=e[s])&&(a=(i<3?o(a):i>3?o(t,n,a):o(t,n))||a);return i>3&&a&&Object.defineProperty(t,n,a),a}var ai=class extends ei{static{this.styles=[s(ii),U]}get toggleMoreLabel(){switch(this.mode){case`half`:return j`${W(`🔼`,`up arrow`)} More`;case`open`:return j`${W(`🔽`,`down arrow`)} Less`;case`closed`:return P}}#e;#t;constructor(){super(),this.toggleReadMore=e=>{this.mode===`open`?this.mode=`half`:this.mode=`open`,e.preventDefault(),e.stopImmediatePropagation()},this.mode=`closed`,this.hasDetail=!1,this.#t=new AbortController,this.#e=new qr(this,{callback:e=>(e[0]?.contentRect.height??0)-(this.header?.clientHeight??0)})}connectedCallback(){super.connectedCallback(),window.addEventListener(`keydown`,this.#n,{signal:this.#t.signal})}disconnectedCallback(){this.#t.abort(),super.disconnectedCallback()}#n=e=>{e.key===`Escape`&&(this.mode=`closed`)};render(){let e=this.mode===`open`,t=this.#e.value;return j`<aside @click=${e=>e.stopPropagation()} class="mr-4 ml-6">
11
+ <header class="w-full py-4">
12
+ <h2>
13
+ <slot name="header"></slot>
14
+ ${R(this.hasDetail,()=>j`<button data-testId="btnReadMoreToggle" class="ml-2 cursor-pointer align-middle" @click=${this.toggleReadMore}>
15
+ ${this.toggleMoreLabel}
16
+ </button>`)}
17
+ </h2>
18
+ </header>
19
+ <div
20
+ style=${t&&e?`height: ${t}px;`:P}
21
+ class=${Yr({"mb-4 motion-safe:transition-max-width":!0,"overflow-y-auto":e})}
22
+ >
23
+ <slot name="summary"></slot>
24
+ ${R(this.hasDetail&&this.mode===`open`,()=>j`<slot name="detail"></slot>`)}
25
+ </div>
26
+ </aside>`}};G([I({reflect:!0})],ai.prototype,`mode`,void 0),G([I({reflect:!0,type:Boolean,attribute:`has-detail`})],ai.prototype,`hasDetail`,void 0),G([I({attribute:!1})],ai.prototype,`toggleMoreLabel`,null),G([Re(`header`)],ai.prototype,`header`,void 0),ai=G([F(`mte-drawer`)],ai);function oi(e){switch(e){case`Killed`:return`success`;case`NoCoverage`:return`caution`;case`Survived`:return`danger`;case`Timeout`:return`warning`;case`Ignored`:case`RuntimeError`:case`Pending`:case`CompileError`:return`secondary`}}function si(e){switch(e){case z.Killing:return`success`;case z.Covering:return`warning`;case z.NotCovering:return`caution`}}function ci(e){switch(e){case z.Killing:return W(`✅`,e);case z.Covering:return W(`☂`,e);case z.NotCovering:return W(`🌧`,e)}}function li(e){switch(e){case`Killed`:return W(`✅`,e);case`NoCoverage`:return W(`🙈`,e);case`Ignored`:return W(`🤥`,e);case`Survived`:return W(`👽`,e);case`Timeout`:return W(`⏰`,e);case`Pending`:return W(`⌛`,e);case`RuntimeError`:case`CompileError`:return W(`💥`,e)}}function ui(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#039;`)}function di(...e){let t=e.filter(Boolean).join(`/`);{let e=new URL(window.location.href);return new URL(`#${t}`,e).href}}function fi(e){return e.length>1?`s`:``}function pi({fileName:e,location:t}){return e?`${e}${t?`:${t.start.line}:${t.start.column}`:``}`:``}function mi(e){e&&!hi(e)&&e.scrollIntoView({block:`center`,behavior:window.matchMedia(`(prefers-reduced-motion: reduce)`).matches?`instant`:`smooth`})}function hi(e){let{top:t,bottom:n}=e.getBoundingClientRect();return t>=0&&n<=(window.innerHeight||document.documentElement.clientHeight)-120}var gi=new fn,_i=br(tr(1),window.navigation?pr(window.navigation,`navigatesuccess`):xn,pr(window,`hashchange`).pipe(Tr(e=>e.preventDefault()))).pipe(rr(()=>window.location.hash.slice(1)),xr(),rr(e=>e.split(`/`).filter(Boolean).map(decodeURIComponent))),K={mutant:`mutant`,test:`test`},q=class extends ei{shouldReactivate(){return!0}reactivate(){this.requestUpdate()}#e=new At;connectedCallback(){super.connectedCallback(),this.#e.add(gi.subscribe(()=>this.shouldReactivate()&&this.reactivate()))}disconnectedCallback(){super.disconnectedCallback(),this.#e.unsubscribe()}},vi=`:host(:not([theme=dark])){--prism-maintext:var(--color-gray-700);--prism-background:var(--color-gray-50);--prism-border:var(--color-gray-200);--prism-cdata:#998;--prism-comment:var(--prism-cdata);--prism-doctype:var(--prism-cdata);--prism-prolog:var(--prism-cdata);--prism-attr-value:#e3116c;--prism-string:var(--prism-attr-value);--prism-boolean:#36acaa;--prism-entity:var(--prism-boolean);--prism-url:var(--prism-boolean);--prism-constant:var(--prism-boolean);--prism-inserted:var(--prism-boolean);--prism-number:var(--prism-boolean);--prism-property:var(--prism-boolean);--prism-regex:var(--prism-boolean);--prism-symbol:var(--prism-boolean);--prism-variable:var(--prism-boolean);--prism-atrule:#00a4db;--prism-attr-name:var(--prism-atrule);--prism-attr:var(--prism-atrule);--prism-operator:var(--prism-maintext);--prism-punctuation:var(--prism-maintext);--prism-deleted:#9a050f;--prism-function:var(--prism-deleted);--prism-function-variable:#6f42c1;--prism-selector:#00009f;--prism-tag:var(--prism-selector);--prism-keyword:var(--prism-selector)}:host([theme=dark]){--prism-maintext:var(--mut-gray-700);--prism-background:var(--mut-gray-50);--prism-border:var(--mut-gray-200);--prism-cdata:#7c7c7c;--prism-comment:var(--prism-cdata);--prism-doctype:var(--prism-cdata);--prism-prolog:var(--prism-cdata);--prism-punctuation:#c5c8c6;--prism-tag:#96cbfe;--prism-property:var(--prism-tag);--prism-keyword:var(--prism-tag);--prism-class-name:#ffffb6;--prism-boolean:#9c9;--prism-constant:var(--prism-boolean);--prism-symbol:#f92672;--prism-deleted:var(--prism-symbol);--prism-number:#ff73fd;--prism-inserted:#a8ff60;--prism-selector:var(--prism-inserted);--prism-attr-name:var(--prism-inserted);--prism-string:var(--prism-inserted);--prism-char:var(--prism-inserted);--prism-builtin:var(--prism-inserted);--prism-variable:#c6c5fe;--prism-operator:#ededed;--prism-entity:#ffffb6;--prism-url:#96cbfe;--prism-attr-value:#f9ee98;--prism-atrule:var(--prism-attr-value);--prism-function:#dad085;--prism-regex:#e9c062;--prism-important:#fd971f}:host(:not([theme=dark])){--mut-file-ts-color:#498ba7;--mut-file-ts-test-color:#cc6d2e;--mut-file-scala-color:#b8383d;--mut-file-java-color:#b8383d;--mut-file-js-color:#b7b73b;--mut-file-js-test-color:#cc6d2e;--mut-file-php-color:#9068b0;--mut-file-html-color:#cc6d2e;--mut-file-csharp-color:#498ba7;--mut-file-vue-color:#7fae42;--mut-file-gherkin-color:#00a818;--mut-file-svelte-color:#b8383d;--mut-file-rust-color:#627379;--mut-file-python-color:#498ba7}:host([theme=dark]){--mut-file-ts-color:#519aba;--mut-file-ts-test-color:#e37933;--mut-file-scala-color:#cc3e44;--mut-file-java-color:#cc3e44;--mut-file-js-color:#cbcb41;--mut-file-js-test-color:#e37933;--mut-file-php-color:#a074c4;--mut-file-html-color:#e37933;--mut-file-csharp-color:#519aba;--mut-file-vue-color:#8dc149;--mut-file-gherkin-color:#10b828;--mut-file-svelte-color:#cc3e44;--mut-file-rust-color:#6d8086;--mut-file-python-color:#519aba}:host{--mut-squiggly-Survived:url("data:image/svg+xml;charset=UTF8,<svg xmlns='http://www.w3.org/2000/svg' height='3' width='6'><g fill='oklch(0.637 0.237 25.331)'><path d='m5.5 0-3 3H1.1l3-3z'/><path d='m4 0 2 2V.6L5.4 0zM0 2l1 1h1.4L0 .6z'/></g></svg>");--mut-squiggly-NoCoverage:url("data:image/svg+xml;charset=UTF8,<svg xmlns='http://www.w3.org/2000/svg' height='3' width='6'><g fill='oklch(0.75 0.183 55.934)'><path d='m5.5 0-3 3H1.1l3-3z'/><path d='m4 0 2 2V.6L5.4 0zM0 2l1 1h1.4L0 .6z'/></g></svg>");color:var(--c)}:host(:not([theme=dark])){--mut-octicon-icon-color:var(--color-primary-600);--mut-line-number:var(--color-gray-400);--mut-diff-add-bg:oklch(from var(--color-green-300) l c h / .3);--mut-diff-add-bg-line-number:oklch(from var(--color-green-300) l c h / .5);--mut-diff-add-line-number:var(--color-gray-600);--mut-diff-del-bg:oklch(from var(--color-red-300) l c h / .3);--mut-diff-del-bg-line-number:oklch(from var(--color-red-300) l c h / .5);--mut-diff-del-line-number:var(--mut-diff-add-line-number)}:host([theme=dark]){--lightningcss-light: ;--lightningcss-dark:initial;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--mut-octicon-icon-color:var(--color-primary-500);--mut-line-number:var(--color-gray-400);--mut-diff-add-bg:oklch(from var(--color-green-600) l c h / .15);--mut-diff-add-bg-line-number:oklch(from var(--color-green-600) l c h / .3);--mut-diff-add-line-number:var(--color-gray-700);--mut-diff-del-bg:oklch(from var(--color-red-600) l c h / .15);--mut-diff-del-bg-line-number:oklch(from var(--color-red-600) l c h / .3);--mut-diff-del-line-number:var(--mut-diff-add-line-number);--mut-white:var(--color-zinc-900);--mut-gray-50:var(--color-zinc-900);--mut-gray-100:var(--color-zinc-800);--mut-gray-200:var(--color-zinc-700);--mut-gray-300:var(--color-zinc-600);--mut-gray-400:var(--color-zinc-500);--mut-gray-500:var(--color-zinc-400);--mut-gray-600:var(--color-zinc-300);--mut-gray-700:var(--color-zinc-200);--mut-gray-800:var(--color-zinc-100);--mut-gray-900:var(--color-zinc-50);--mut-primary-100:var(--color-sky-800);--mut-primary-200:var(--color-sky-700);--mut-primary-800:var(--color-sky-100);--mut-primary-900:var(--color-sky-50);--mut-primary-on:var(--color-sky-500)}`,yi=100,J=class extends q{get themeBackgroundColor(){return getComputedStyle(this).getPropertyValue(`--color-white`)}#e=new AbortController;get title(){return this.context.result?this.titlePostfix?`${this.context.result.name} - ${this.titlePostfix}`:this.context.result.name:``}constructor(){super(),this.themeSwitch=e=>{this.theme=e.detail,Er()&&localStorage.setItem(`mutation-testing-elements-theme`,this.theme)},this.context={view:K.mutant,path:[]},this.path=[],this.#u=new At,this.#d=new At,this.#u.add(this.#d)}firstUpdated(){(this.path.length===0||this.path[0]!==K.mutant&&this.path[0]!==K.test)&&window.location.replace(di(`${K.mutant}`))}async#t(){if(this.src)try{this.report=await(await fetch(this.src)).json()}catch(e){this.errorMessage=String(e)}}willUpdate(e){this.report&&(this.theme??=this.#a(),e.has(`report`)&&this.#o(this.report),(e.has(`path`)||e.has(`report`))&&(this.#s(),this.#c())),e.has(`src`)&&this.#t()}#n=new Map;#r=new Map;updated(e){e.has(`theme`)&&this.theme&&this.dispatchEvent(H(`theme-changed`,{theme:this.theme,themeBackgroundColor:this.themeBackgroundColor}))}#i=()=>{this.theme=this.#a()};#a(){return Er()&&localStorage.getItem(`mutation-testing-elements-theme`)||(window.matchMedia?.(`(prefers-color-scheme: dark)`)?.matches?`dark`:`light`)}#o(e){this.rootModel=lt(e),t((e,t)=>{e.result=t,e.mutants.forEach(e=>this.#n.set(e.id,e))})(this.rootModel?.systemUnderTestMetrics),t((e,t)=>{e.result=t,e.tests.forEach(e=>this.#r.set(e.id,e))})(this.rootModel?.testMetrics),this.rootModel.systemUnderTestMetrics.updateParent(),this.rootModel.testMetrics?.updateParent();function t(e){return function t(n){n?.file&&e(n.file,n),n?.childResults.forEach(e=>{t(e)})}}}#s(){if(this.rootModel){let e=(e,t)=>t.reduce((e,t)=>e?.childResults.find(e=>e.name===t),e),t=this.path.slice(1);this.path[0]===K.test&&this.rootModel.testMetrics?this.context={view:K.test,path:t,result:e(this.rootModel.testMetrics,this.path.slice(1))}:this.context={view:K.mutant,path:t,result:e(this.rootModel.systemUnderTestMetrics,this.path.slice(1))}}}#c(){document.title=this.title}static{this.styles=[s(vi),U]}connectedCallback(){super.connectedCallback(),window.matchMedia(`(prefers-color-scheme: dark)`).addEventListener?.(`change`,this.#i,{signal:this.#e.signal}),this.#u.add(_i.subscribe(e=>this.path=e)),this.#m()}#l;#u;#d;#f;#p;#m(){if(!this.sse)return;this.#l=new EventSource(this.sse);let e=pr(this.#l,`mutant-tested`).subscribe(e=>{let t=JSON.parse(e.data);if(!this.report)return;let n=this.#n.get(t.id);if(n!==void 0){this.#f=n;for(let[e,n]of Object.entries(t))this.#f[e]=n;t.killedBy&&t.killedBy.forEach(e=>{let t=this.#r.get(e);t!==void 0&&(this.#p=t,t.addKilled(this.#f),this.#f.addKilledBy(t))}),t.coveredBy&&t.coveredBy.forEach(e=>{let t=this.#r.get(e);t!==void 0&&(this.#p=t,t.addCovered(this.#f),this.#f.addCoveredBy(t))})}}),t=pr(this.#l,`mutant-tested`).pipe(wr(yi)).subscribe(()=>{this.#h()});this.#d.add(e),this.#d.add(t),this.#l.addEventListener(`finished`,()=>{this.#l?.close(),this.#h(),this.#d.unsubscribe()},{signal:this.#e.signal})}#h(){this.#f?.update(),this.#p?.update(),gi.next()}disconnectedCallback(){super.disconnectedCallback(),this.#e.abort(),this.#u.unsubscribe(),this.#l?.close()}#g(){return R(this.context.result,e=>j`<h1 class="mt-4 text-5xl font-bold tracking-tight">
27
+ ${e.name}${R(this.titlePostfix,e=>j`<small class="text-light-muted ml-4 font-light">${e}</small>`)}
28
+ </h1>`)}render(){return R(this.context.result??this.errorMessage,()=>j`<mte-file-picker .rootModel=${this.rootModel}></mte-file-picker>
29
+ <div class="container space-y-4 bg-white pb-4 font-sans text-gray-800 transition-colors motion-safe:transition-max-width">
30
+ ${this.#_()}
31
+ <mte-theme-switch @theme-switch=${this.themeSwitch} class="sticky top-offset z-20 float-right mb-0 pt-7" .theme=${this.theme}>
32
+ </mte-theme-switch>
33
+ ${this.#g()} ${this.#v()}
34
+ <mte-breadcrumb
35
+ @mte-file-picker-open=${()=>this.filePicker.open()}
36
+ .view=${this.context.view}
37
+ .path=${this.context.path}
38
+ ></mte-breadcrumb>
39
+ <mte-result-status-bar
40
+ detected=${ze(this.rootModel?.systemUnderTestMetrics.metrics.totalDetected)}
41
+ no-coverage=${ze(this.rootModel?.systemUnderTestMetrics.metrics.noCoverage)}
42
+ pending=${ze(this.rootModel?.systemUnderTestMetrics.metrics.pending)}
43
+ survived=${ze(this.rootModel?.systemUnderTestMetrics.metrics.survived)}
44
+ total=${ze(this.rootModel?.systemUnderTestMetrics.metrics.totalValid)}
45
+ ></mte-result-status-bar>
46
+ ${R(this.context.view===`mutant`&&this.context.result,()=>j`<mte-mutant-view
47
+ id="mte-mutant-view"
48
+ .result=${this.context.result}
49
+ .thresholds=${this.report.thresholds}
50
+ .path=${this.path}
51
+ ></mte-mutant-view>`)}
52
+ ${R(this.context.view===`test`&&this.context.result,()=>j`<mte-test-view id="mte-test-view" .result=${this.context.result} .path=${this.path}></mte-test-view>`)}
53
+ </div>`)}#_(){return R(this.errorMessage,e=>j`<div class="my-4 rounded-lg bg-red-100 p-4 text-sm text-red-700" role="alert">${e}</div>`)}#v(){return R(this.rootModel?.testMetrics,()=>{let e=this.context.view===`mutant`,t=this.context.view===`test`;return j`<nav class="border-b border-gray-200 text-center text-sm font-medium text-gray-600">
54
+ <ul class="-mb-px flex flex-wrap" role="tablist">
55
+ ${[{type:`mutant`,isActive:e,text:`👽 Mutants`},{type:`test`,isActive:t,text:`🧪 Tests`}].map(({type:e,isActive:t,text:n})=>j`<li class="mr-2" role="presentation">
56
+ <a
57
+ class="inline-block rounded-t-lg border-b-2 border-transparent p-4 transition-colors hover:border-gray-300 hover:bg-gray-200 hover:text-gray-700 aria-selected:border-b-[3px] aria-selected:border-solid aria-selected:border-primary-700 aria-selected:text-primary-on"
58
+ role="tab"
59
+ href=${di(e)}
60
+ aria-selected=${t}
61
+ aria-controls="mte-${e}-view"
62
+ >${n}</a
63
+ >
64
+ </li>`)}
65
+ </ul>
66
+ </nav>`})}};G([I({attribute:!1})],J.prototype,`report`,void 0),G([I({attribute:!1})],J.prototype,`rootModel`,void 0),G([I()],J.prototype,`src`,void 0),G([I()],J.prototype,`sse`,void 0),G([I({attribute:!1})],J.prototype,`errorMessage`,void 0),G([I({attribute:!1})],J.prototype,`context`,void 0),G([I({type:Array})],J.prototype,`path`,void 0),G([I({attribute:`title-postfix`})],J.prototype,`titlePostfix`,void 0),G([I({reflect:!0})],J.prototype,`theme`,void 0),G([I({attribute:!1})],J.prototype,`themeBackgroundColor`,null),G([Re(`mte-file-picker`)],J.prototype,`filePicker`,void 0),G([I()],J.prototype,`title`,null),J=G([F(`mutation-test-report-app`)],J);function*bi(e,t){if(e!==void 0){let n=0;for(let r of e)yield t(r,n++)}}var xi=(e,t,n)=>{let r=new Map;for(let i=t;i<=n;i++)r.set(e[i],i);return r},Y=Rr(class extends zr{constructor(e){if(super(e),e.type!==Lr.CHILD)throw Error(`repeat() can only be used in text expressions`)}dt(e,t,n){let r;n===void 0?n=t:t!==void 0&&(r=t);let i=[],a=[],o=0;for(let t of e)i[o]=r?r(t,o):o,a[o]=n(t,o),o++;return{values:a,keys:i}}render(e,t,n){return this.dt(e,t,n).values}update(e,[t,n,r]){let i=Fr(e),{values:a,keys:o}=this.dt(t,n,r);if(!Array.isArray(i))return this.ut=o,a;let s=this.ut??=[],c=[],l,u,d=0,f=i.length-1,p=0,m=a.length-1;for(;d<=f&&p<=m;)if(i[d]===null)d++;else if(i[f]===null)f--;else if(s[d]===o[p])c[p]=Mr(i[d],a[p]),d++,p++;else if(s[f]===o[m])c[m]=Mr(i[f],a[m]),f--,m--;else if(s[d]===o[m])c[m]=Mr(i[d],a[m]),jr(e,c[m+1],i[d]),d++,m--;else if(s[f]===o[p])c[p]=Mr(i[f],a[p]),jr(e,i[d],i[f]),f--,p++;else if(l===void 0&&(l=xi(o,p,m),u=xi(s,d,f)),l.has(s[d]))if(l.has(s[f])){let t=u.get(o[p]),n=t===void 0?null:i[t];if(n===null){let t=jr(e,i[d]);Mr(t,a[p]),c[p]=t}else c[p]=Mr(n,a[p]),jr(e,i[d],n),i[t]=null;p++}else Ir(i[f]),f--;else Ir(i[d]),d++;for(;p<=m;){let t=jr(e,c[m+1]);Mr(t,a[p]),c[p++]=t}for(;d<=f;){let e=i[d++];e!==null&&Ir(e)}return this.ut=o,Pr(e,c),N}}),X={csharp:`cs`,java:`java`,javascript:`javascript`,html:`html`,php:`php`,scala:`scala`,typescript:`typescript`,vue:`vue`,gherkin:`gherkin`,svelte:`svelte`,rust:`rust`,python:`python`};function Si(e){return e.substr(e.lastIndexOf(`.`)+1).toLocaleLowerCase()}function Ci(e){switch(Si(e)){case`cs`:return X.csharp;case`html`:return X.html;case`java`:return X.java;case`js`:case`cjs`:case`mjs`:return X.javascript;case`ts`:case`tsx`:case`cts`:case`mts`:return X.typescript;case`sc`:case`sbt`:case`scala`:return X.scala;case`php`:return X.php;case`vue`:return X.vue;case`feature`:return X.gherkin;case`svelte`:return X.svelte;case`rs`:return X.rust;case`py`:return X.python;default:return}}function wi(e,t){let n=Ci(t)??`plain`,r=n;return n===X.vue&&(r=X.html),(0,Xr.highlight)(e,Xr.languages[r],r)}function Ti(e,t){let n=[],r=[],i={column:0,line:1,offset:-1},a=[],o=!1,s=0;for(;s<e.length;){switch(o&&!Ei(e[s])&&(l(),o=!1),e[s]){case Z.CarriageReturn:i.offset++;break;case Z.NewLine:f(),i.offset++,i.line++,i.column=0,o=!0;break;case Z.LT:{let e=m();e.isClosing?g(e):h(e);break}case Z.Amp:p(b());break;default:p(e[s]);break}s++}return f(),r;function c(...e){n.push(...e)}function l(){a.forEach(e=>c(d(e)))}function u(){a.forEach(e=>c(d({...e,isClosing:!0})))}function d({attributes:e,elementName:t,isClosing:n}){return n?`</${t}>`:`<${t}${Object.entries(e??{}).reduce((e,[t,n])=>n===void 0?`${e} ${t}`:`${e} ${t}="${n}"`,``)}>`}function f(){u(),r.push(n.join(``)),n=[]}function p(e){if(i.column++,i.offset++,t)for(let e of t(i))e.isClosing?g(e):(c(d(e)),a.push(e));c(e)}function m(){s++;let t=e[s]===`/`?!0:void 0;t&&s++;let n=s;for(;!Ei(e[s])&&e[s]!==Z.GT;)s++;return{elementName:e.substring(n,s),attributes:_(),isClosing:t}}function h(e){a.push(e),c(d(e))}function g(e){let t;for(t=a.length-1;t>=0;t--){let n=a[t];if(e.elementName===n.elementName&&n.id===e.id){c(d(e)),a.splice(t,1);for(let e=t;e<a.length;e++)c(d(a[e]));break}c(d({...n,isClosing:!0}))}if(t===-1)throw Error(`Cannot find corresponding opening tag for ${d(e)}`)}function _(){let t=Object.create(null);for(;s<e.length;){let n=e[s];if(n===Z.GT)return t;if(!Ei(n)){let{name:e,value:n}=v();t[e]=n}s++}throw Error(`Missing closing tag near ${e.substr(s-10)}`)}function v(){let t=s;for(;e[s]!==`=`;)s++;let n=e.substring(t,s);return s++,{name:n,value:y()}}function y(){e[s]===`"`&&s++;let t=s;for(;e[s]!==`"`;)s++;return e.substring(t,s)}function b(){let t=s;for(;e[s]!==Z.Semicolon;)s++;return e.substring(t,s+1)}}function Ei(e){return e===Z.NewLine||e===Z.Space||e===Z.Tab}var Z={CarriageReturn:`\r`,NewLine:`
67
+ `,Space:` `,Amp:`&`,Semicolon:`;`,LT:`<`,GT:`>`,Tab:` `};function Di(e,t){let n=0,r=t.length-1;for(;e[n]===t[n]&&n<t.length;)n++;let i=e.length-t.length;for(;e[r+i]===t[r]&&r>n;)r--;r===n&&(Ei(t[n-1])||n--),r++;let a=t.substring(n,r);return[`true`,`false`].forEach(e=>{a===e.substr(0,e.length-1)&&e.endsWith(t[r])&&r++,a===e.substr(1,e.length)&&e.startsWith(t[n-1])&&n--}),[n,r]}function Oi(e,t){return e.line>t.line||e.line===t.line&&e.column>=t.column}var ki=`#report-code-block{background:var(--prism-background);border:1px solid var(--prism-border);overflow:auto visible}.line-numbers{counter-reset:mte-line-number}.line .line-number{text-align:right;color:var(--mut-line-number);counter-increment:mte-line-number;padding:0 10px 0 15px}.line .line-number:before{content:counter(mte-line-number)}.line-marker:before{content:" ";padding:0 5px}.NoCoverage{--mut-status-color:var(--color-orange-500);--mut-squiggly-line:var(--mut-squiggly-NoCoverage)}.Survived{--mut-status-color:var(--color-red-500);--mut-squiggly-line:var(--mut-squiggly-Survived)}.Pending{--mut-status-color:var(--color-neutral-400)}.Killed{--mut-status-color:var(--color-green-600)}.Timeout{--mut-status-color:var(--color-amber-400)}.CompileError,.RuntimeError,.Ignored{--mut-status-color:var(--color-neutral-400)}svg.mutant-dot{fill:var(--mut-status-color)}.mte-selected-Pending .mutant.Pending,.mte-selected-Killed .mutant.Killed,.mte-selected-Timeout .mutant.Timeout,.mte-selected-CompileError .mutant.CompileError,.mte-selected-RuntimeError .mutant.RuntimeError,.mte-selected-Ignored .mutant.Ignored{-webkit-text-decoration:solid underline var(--mut-status-color) 2px;-webkit-text-decoration:solid underline var(--mut-status-color) 2px;text-decoration:solid underline var(--mut-status-color) 2px;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none;text-underline-offset:3px;cursor:pointer}.mte-selected-Survived .mutant.Survived,.mte-selected-NoCoverage .mutant.NoCoverage{border-bottom-style:solid;border-image-slice:0 0 4;border-image-width:4px;border-image-outset:6px;border-image-repeat:repeat;border-image-source:var(--mut-squiggly-line);cursor:pointer}:is(.mte-selected-Survived .mutant.Survived,.mte-selected-NoCoverage .mutant.NoCoverage) .mutant.Survived,:is(.mte-selected-Survived .mutant.Survived,.mte-selected-NoCoverage .mutant.NoCoverage) .mutant.NoCoverage{border-bottom-style:none;border-image-source:none;text-decoration-line:none}.diff-old{background-color:var(--mut-diff-del-bg)}.diff-focus{background-color:var(--mut-diff-add-bg-line-number)}.diff-old .line-number{background-color:var(--mut-diff-del-bg-line-number);color:var(--mut-diff-del-line-number)}.diff-old .line-marker:before{content:"-"}.diff-new{background-color:var(--mut-diff-add-bg)}.diff-new .empty-line-number{background-color:var(--mut-diff-add-bg-line-number);color:var(--mut-diff-add-line-number)}.diff-new .line-marker:before{content:"+"}`,Ai=class extends zr{constructor(e){if(super(e),this.it=P,e.type!==Lr.CHILD)throw Error(this.constructor.directiveName+`() can only be used in child bindings`)}render(e){if(e===P||e==null)return this._t=void 0,this.it=e;if(e===N)return e;if(typeof e!=`string`)throw Error(this.constructor.directiveName+`() called with a non-string value`);if(e===this.it)return this._t;this.it=e;let t=[e];return t.raw=t,this._t={_$litType$:this.constructor.resultType,strings:t,values:[]}}};Ai.directiveName=`unsafeHTML`,Ai.resultType=1;var ji=Rr(Ai);function Mi(e,t){return e===P&&t===P?P:j`<span class="ml-1 flex flex-row items-center">${e}${t}</span>`}function Ni(e,t){return j`<tr class="line"
68
+ ><td class="line-number"></td><td class="line-marker"></td><td class="code flex"><span>${ji(e)}</span>${t}</td></tr
69
+ >`}var Pi=`M 0,5 C 0,-1.66 10,-1.66 10,5 10,7.76 7.76,10 5,10 2.24,10 0,7.76 0,5 Z`,Fi=`M 0,0 C 0,0 10,0 10,0 10,0 5,10 5,10 5,10 0,0 0,0 Z`,Ii=`0.4 0 0.2 1`,Li=(e,t,n)=>M`<path stroke-opacity="${n}" class="stroke-gray-800 transition-stroke-opacity" d="${t}">
70
+ <animate values="${e};${t}" attributeName="d" dur="0.2s" begin="indefinite" calcMode="spline" keySplines="${Ii}" />
71
+ </path>`,Ri=Li(Pi,Fi,1),zi=Li(Fi,Pi,0);function Bi(e,t,n){(e?.querySelector(`[${t}="${encodeURIComponent(n)}"] path animate`))?.beginElement()}var Vi=`diff-old`,Hi=`diff-new`,Ui=class extends q{static{this.styles=[$r,U,s(ki)]}#e;constructor(){super(),this.filters=[],this.selectedMutantStates=[],this.lines=[],this.mutants=[],this.#e=new AbortController}connectedCallback(){super.connectedCallback(),window.addEventListener(`keydown`,this.#t,{signal:this.#e.signal})}disconnectedCallback(){this.#e.abort(),super.disconnectedCallback()}#t=e=>{e.key===`Escape`&&this.selectedMutant&&this.#s(this.selectedMutant)};#n=e=>{this.selectedMutantStates=e.detail.concat([`Pending`])};#r=e=>{if(e.stopPropagation(),e.target instanceof Element){let t=e.target,n=[];for(;t instanceof Element;t=t.parentElement){let e=t.getAttribute(`data-mutant-id`),r=this.mutants.find(({id:t})=>t.toString()===e);r&&n.push(r)}let r=(this.selectedMutant?n.indexOf(this.selectedMutant):-1)+1;n[r]?(this.#s(n[r]),Gi()):this.selectedMutant&&(this.#s(this.selectedMutant),Gi())}};render(){let e=Map.groupBy(this.mutants,e=>e.location.start.line),t=this.#o(Array.from(e.entries()).filter(([e])=>e>this.lines.length).flatMap(([,e])=>e));return j`<mte-state-filter
72
+ allow-toggle-all
73
+ .filters=${this.filters}
74
+ @filters-changed=${this.#n}
75
+ @next=${this.#i}
76
+ @previous=${this.#a}
77
+ ></mte-state-filter>
78
+ <pre
79
+ @click=${this.#r}
80
+ id="report-code-block"
81
+ class="line-numbers ${this.selectedMutantStates.map(e=>`mte-selected-${e}`).join(` `)} flex rounded-md py-4"
82
+ >
83
+ <code class="flex language-${this.model.language}">
84
+ <table>${bi(this.lines,(n,r)=>{let i=r+1;return Ni(n,Mi(this.#o(e.get(i)),this.lines.length===i?t:P))})}</table>
85
+ </code>
86
+ </pre>`}#i=()=>{let e=this.selectedMutant?(this.mutants.indexOf(this.selectedMutant)+1)%this.mutants.length:0;this.mutants[e]&&this.#s(this.mutants[e])};#a=()=>{let e=this.selectedMutant?(this.mutants.indexOf(this.selectedMutant)+this.mutants.length-1)%this.mutants.length:this.mutants.length-1;this.mutants[e]&&this.#s(this.mutants[e])};#o(e){return R(e?.length,()=>Y(e,e=>e.id,e=>M`<svg
87
+ data-mutant-id="${e.id}"
88
+ class="mutant-dot ${this.selectedMutant?.id===e.id?`selected`:``} ${e.status} mx-0.5 cursor-pointer"
89
+ height="11"
90
+ width="11"
91
+ >
92
+ <title>${Wi(e)}</title>
93
+ ${this.selectedMutant?.id===e.id?Ri:zi}
94
+ </svg>`),()=>P)}#s(e){if(this.#c(),this.#f(e),this.selectedMutant===e){this.selectedMutant=void 0,this.dispatchEvent(H(`mutant-selected`,{selected:!1,mutant:e}));return}else this.selectedMutant&&this.#f(this.selectedMutant);this.selectedMutant=e;let t=this.code.querySelectorAll(`tr.line`);for(let n=e.location.start.line-1;n<e.location.end.line;n++)t.item(n).classList.add(Vi);let n=this.#d(e),r=t.item(e.location.end.line-1);r.insertAdjacentHTML(`afterend`,n),mi(r),this.dispatchEvent(H(`mutant-selected`,{selected:!0,mutant:e}))}#c(){let e=this.code;e.querySelectorAll(`.${Vi}`).forEach(e=>e.classList.remove(Vi)),e.querySelectorAll(`.${Hi}`).forEach(e=>e.remove())}reactivate(){super.reactivate(),this.#l()}update(e){e.has(`model`)&&this.model&&this.#l(),(e.has(`model`)&&this.model||e.has(`selectedMutantStates`))&&(this.mutants=this.model.mutants.filter(e=>this.selectedMutantStates.includes(e.status)).sort((e,t)=>Oi(e.location.start,t.location.start)?1:-1),this.selectedMutant&&!this.mutants.includes(this.selectedMutant)&&e.has(`selectedMutantStates`)&&this.#u(e.get(`selectedMutantStates`)??[])&&this.#s(this.selectedMutant)),super.update(e)}#l(){this.filters=[`Killed`,`Survived`,`NoCoverage`,`Ignored`,`Timeout`,`CompileError`,`RuntimeError`].filter(e=>this.model.mutants.some(t=>t.status===e)).map(e=>({enabled:[...this.selectedMutantStates,`Survived`,`NoCoverage`,`Timeout`].includes(e),count:this.model.mutants.filter(t=>t.status===e).length,status:e,label:j`${li(e)} ${e}`,context:oi(e)}));let e=wi(this.model.source,this.model.name),t=new Set,n=new Set(this.model.mutants);this.lines=Ti(e,function*(e){for(let n of t)Oi(e,n.location.end)&&(t.delete(n),yield{elementName:`span`,id:n.id,isClosing:!0});for(let r of n)Oi(e,r.location.start)&&(t.add(r),n.delete(r),yield{elementName:`span`,id:r.id,attributes:{class:ui(`mutant border-none ${r.status}`),title:ui(Wi(r)),"data-mutant-id":ui(r.id.toString())}})})}#u(e){return e.length===this.selectedMutantStates.length?!e.every((e,t)=>this.selectedMutantStates[t]===e):!0}#d(e){let t=e.getMutatedLines().trimEnd(),[n,r]=Di(e.getOriginalLines().trimEnd(),t),i=Ti(wi(t,this.model.name),function*({offset:e}){e===n?yield{elementName:`span`,id:`diff-focus`,attributes:{class:`diff-focus`}}:e===r&&(yield{elementName:`span`,id:`diff-focus`,isClosing:!0})}),a=`<tr class="${Hi}"><td class="empty-line-number"></td><td class="line-marker"></td><td class="code">`;return i.map(e=>`${a}${e}</td></tr>`).join(``)}#f(e){Bi(this.code,`data-mutant-id`,e.id)}};G([L()],Ui.prototype,`filters`,void 0),G([I({attribute:!1})],Ui.prototype,`model`,void 0),G([L()],Ui.prototype,`selectedMutantStates`,void 0),G([L()],Ui.prototype,`selectedMutant`,void 0),G([L()],Ui.prototype,`lines`,void 0),G([L()],Ui.prototype,`mutants`,void 0),G([Re(`code`)],Ui.prototype,`code`,void 0),Ui=G([F(`mte-file`)],Ui);function Wi(e){return`${e.mutatorName} ${e.status}`}function Gi(){window.getSelection()?.removeAllRanges()}var Ki=(e,t,n)=>{if(!e)return n?.all?ia(t,n):Ca;var r=ra(e),i=r.bitflags,a=r.containsSpace,o=ea(n?.threshold||0),s=n?.limit||Sa,c=0,l=0,u=t.length;function d(e){c<s?(Ta.add(e),++c):(++l,e._score>Ta.peek()._score&&Ta.replaceTop(e))}if(n?.key)for(var f=n.key,p=0;p<u;++p){var m=t[p],h=ba(m,f);if(h&&(xa(h)||(h=na(h)),(i&h._bitflags)===i)){var g=aa(r,h);g!==$&&(g._score<o||(g.obj=m,d(g)))}}else if(n?.keys){var _=n.keys,v=_.length;outer:for(var p=0;p<u;++p){for(var m=t[p],y=0,b=0;b<v;++b){var f=_[b],h=ba(m,f);if(!h){va[b]=wa;continue}xa(h)||(h=na(h)),va[b]=h,y|=h._bitflags}if((i&y)===i){if(a)for(let e=0;e<r.spaceSearches.length;e++)ga[e]=Q;for(var b=0;b<v;++b){if(h=va[b],h===wa){ya[b]=wa;continue}if(ya[b]=aa(r,h,!1,a),ya[b]===$){ya[b]=wa;continue}if(a)for(let e=0;e<r.spaceSearches.length;e++){if(_a[e]>-1e3&&ga[e]>Q){var x=(ga[e]+_a[e])/4;x>ga[e]&&(ga[e]=x)}_a[e]>ga[e]&&(ga[e]=_a[e])}}if(a){for(let e=0;e<r.spaceSearches.length;e++)if(ga[e]===Q)continue outer}else{var S=!1;for(let e=0;e<v;e++)if(ya[e]._score!==Q){S=!0;break}if(!S)continue}var C=new Zi(v);for(let e=0;e<v;e++)C[e]=ya[e];if(a){var w=0;for(let e=0;e<r.spaceSearches.length;e++)w+=ga[e]}else{var w=Q;for(let e=0;e<v;e++){var g=C[e];if(g._score>-1e3&&w>Q){var x=(w+g._score)/4;x>w&&(w=x)}g._score>w&&(w=g._score)}}if(C.obj=m,C._score=w,n?.scoreFn){if(w=n.scoreFn(C),!w)continue;w=ea(w),C._score=w}w<o||d(C)}}}else for(var p=0;p<u;++p){var h=t[p];if(h&&(xa(h)||(h=na(h)),(i&h._bitflags)===i)){var g=aa(r,h);g!==$&&(g._score<o||d(g))}}if(c===0)return Ca;for(var T=Array(c),p=c-1;p>=0;--p)T[p]=Ta.poll();return T.total=c+l,T},qi=(e,t=`<b>`,n=`</b>`)=>{for(var r=typeof t==`function`?t:void 0,i=e.target,a=i.length,o=e.indexes,s=``,c=0,l=0,u=!1,d=[],f=0;f<a;++f){var p=i[f];if(o[l]===f){if(++l,u||(u=!0,r?(d.push(s),s=``):s+=t),l===o.length){r?(s+=p,d.push(r(s,c++)),s=``,d.push(i.substr(f+1))):s+=p+n+i.substr(f+1);break}}else u&&(u=!1,r?(d.push(r(s,c++)),s=``):s+=n);s+=p}return r?d:s},Ji=e=>{typeof e==`number`?e=``+e:typeof e!=`string`&&(e=``);var t=ca(e);return Qi(e,{_targetLower:t._lower,_targetLowerCodes:t.lowerCodes,_bitflags:t.bitflags})},Yi=()=>{da.clear(),fa.clear()},Xi=class{get indexes(){return this._indexes.slice(0,this._indexes.len).sort((e,t)=>e-t)}set indexes(e){return this._indexes=e}highlight(e,t){return qi(this,e,t)}get score(){return $i(this._score)}set score(e){this._score=ea(e)}},Zi=class extends Array{get score(){return $i(this._score)}set score(e){this._score=ea(e)}},Qi=(e,t)=>{let n=new Xi;return n.target=e,n.obj=t.obj??$,n._score=t._score??Q,n._indexes=t._indexes??[],n._targetLower=t._targetLower??``,n._targetLowerCodes=t._targetLowerCodes??$,n._nextBeginningIndexes=t._nextBeginningIndexes??$,n._bitflags=t._bitflags??0,n},$i=e=>e===Q?0:e>1?e:Math.E**(((-e+1)**.04307-1)*-2),ea=e=>e===0?Q:e>1?e:1-(Math.log(e)/-2+1)**(1/.04307),ta=e=>{typeof e==`number`?e=``+e:typeof e!=`string`&&(e=``),e=e.trim();var t=ca(e),n=[];if(t.containsSpace){var r=e.split(/\s+/);r=[...new Set(r)];for(var i=0;i<r.length;i++)if(r[i]!==``){var a=ca(r[i]);n.push({lowerCodes:a.lowerCodes,_lower:r[i].toLowerCase(),containsSpace:!1})}}return{lowerCodes:t.lowerCodes,_lower:t._lower,containsSpace:t.containsSpace,bitflags:t.bitflags,spaceSearches:n}},na=e=>{if(e.length>999)return Ji(e);var t=da.get(e);return t===void 0?(t=Ji(e),da.set(e,t),t):t},ra=e=>{if(e.length>999)return ta(e);var t=fa.get(e);return t===void 0?(t=ta(e),fa.set(e,t),t):t},ia=(e,t)=>{var n=[];n.total=e.length;var r=t?.limit||Sa;if(t?.key)for(var i=0;i<e.length;i++){var a=e[i],o=ba(a,t.key);if(o!=$){xa(o)||(o=na(o));var s=Qi(o.target,{_score:o._score,obj:a});if(n.push(s),n.length>=r)return n}}else if(t?.keys)for(var i=0;i<e.length;i++){for(var a=e[i],c=new Zi(t.keys.length),l=t.keys.length-1;l>=0;--l){var o=ba(a,t.keys[l]);if(!o){c[l]=wa;continue}xa(o)||(o=na(o)),o._score=Q,o._indexes.len=0,c[l]=o}if(c.obj=a,c._score=Q,n.push(c),n.length>=r)return n}else for(var i=0;i<e.length;i++){var o=e[i];if(o!=$&&(xa(o)||(o=na(o)),o._score=Q,o._indexes.len=0,n.push(o),n.length>=r))return n}return n},aa=(e,t,n=!1,r=!1)=>{if(n===!1&&e.containsSpace)return oa(e,t,r);for(var i=e._lower,a=e.lowerCodes,o=a[0],s=t._targetLowerCodes,c=a.length,l=s.length,u=0,d=0,f=0;;){var p=o===s[d];if(p){if(pa[f++]=d,++u,u===c)break;o=a[u]}if(++d,d>=l)return $}var u=0,m=!1,h=0,g=t._nextBeginningIndexes;g===$&&(g=t._nextBeginningIndexes=ua(t.target)),d=pa[0]===0?0:g[pa[0]-1];var _=0;if(d!==l)for(;;)if(d>=l){if(u<=0||(++_,_>200))break;--u;var v=ma[--h];d=g[v]}else{var p=a[u]===s[d];if(p){if(ma[h++]=d,++u,u===c){m=!0;break}++d}else d=g[d]}var y=c<=1?-1:t._targetLower.indexOf(i,pa[0]),b=!!~y,x=b?y===0||t._nextBeginningIndexes[y-1]===y:!1;if(b&&!x){for(var S=0;S<g.length;S=g[S])if(!(S<=y)){for(var C=0;C<c&&a[C]===t._targetLowerCodes[S+C];C++);if(C===c){y=S,x=!0;break}}}var w=e=>{for(var t=0,n=0,r=1;r<c;++r)e[r]-e[r-1]!==1&&(t-=e[r],++n);var i=e[c-1]-e[0]-(c-1);if(t-=(12+i)*n,e[0]!==0&&(t-=e[0]*e[0]*.2),!m)t*=1e3;else{for(var a=1,r=g[0];r<l;r=g[r])++a;a>24&&(t*=(a-24)*10)}return t-=(l-c)/2,b&&(t/=1+c*c*1),x&&(t/=1+c*c*1),t-=(l-c)/2,t};if(!m){if(b)for(var S=0;S<c;++S)pa[S]=y+S;var T=pa,E=w(T)}else if(x){for(var S=0;S<c;++S)pa[S]=y+S;var T=pa,E=w(pa)}else var T=ma,E=w(ma);t._score=E;for(var S=0;S<c;++S)t._indexes[S]=T[S];t._indexes.len=c;let D=new Xi;return D.target=t.target,D._score=t._score,D._indexes=t._indexes,D},oa=(e,t,n)=>{for(var r=new Set,i=0,a=$,o=0,s=e.spaceSearches,c=s.length,l=0,u=()=>{for(let e=l-1;e>=0;e--)t._nextBeginningIndexes[ha[e*2+0]]=ha[e*2+1]},d=!1,f=0;f<c;++f){_a[f]=Q;var p=s[f];if(a=aa(p,t),n){if(a===$)continue;d=!0}else if(a===$)return u(),$;if(f!==c-1){var m=a._indexes,h=!0;for(let e=0;e<m.len-1;e++)if(m[e+1]-m[e]!==1){h=!1;break}if(h){var g=m[m.len-1]+1,_=t._nextBeginningIndexes[g-1];for(let e=g-1;e>=0&&_===t._nextBeginningIndexes[e];e--)t._nextBeginningIndexes[e]=g,ha[l*2+0]=e,ha[l*2+1]=_,l++}}i+=a._score/c,_a[f]=a._score/c,a._indexes[0]<o&&(i-=(o-a._indexes[0])*2),o=a._indexes[0];for(var v=0;v<a._indexes.len;++v)r.add(a._indexes[v])}if(n&&!d)return $;u();var y=aa(e,t,!0);if(y!==$&&y._score>i){if(n)for(var f=0;f<c;++f)_a[f]=y._score/c;return y}n&&(a=t),a._score=i;var f=0;for(let e of r)a._indexes[f++]=e;return a._indexes.len=f,a},sa=e=>e.replace(/\p{Script=Latin}+/gu,e=>e.normalize(`NFD`)).replace(/[\u0300-\u036f]/g,``),ca=e=>{e=sa(e);for(var t=e.length,n=e.toLowerCase(),r=[],i=0,a=!1,o=0;o<t;++o){var s=r[o]=n.charCodeAt(o);if(s===32){a=!0;continue}var c=s>=97&&s<=122?s-97:s>=48&&s<=57?26:s<=127?30:31;i|=1<<c}return{lowerCodes:r,bitflags:i,containsSpace:a,_lower:n}},la=e=>{for(var t=e.length,n=[],r=0,i=!1,a=!1,o=0;o<t;++o){var s=e.charCodeAt(o),c=s>=65&&s<=90,l=c||s>=97&&s<=122||s>=48&&s<=57,u=c&&!i||!a||!l;i=c,a=l,u&&(n[r++]=o)}return n},ua=e=>{e=sa(e);for(var t=e.length,n=la(e),r=[],i=n[0],a=0,o=0;o<t;++o)i>o?r[o]=i:(i=n[++a],r[o]=i===void 0?t:i);return r},da=new Map,fa=new Map,pa=[],ma=[],ha=[],ga=[],_a=[],va=[],ya=[],ba=(e,t)=>{var n=e[t];if(n!==void 0)return n;if(typeof t==`function`)return t(e);var r=t;Array.isArray(t)||(r=t.split(`.`));for(var i=r.length,a=-1;e&&++a<i;)e=e[r[a]];return e},xa=e=>typeof e==`object`&&typeof e._bitflags==`number`,Sa=1/0,Q=-Sa,Ca=[];Ca.total=0;var $=null,wa=Ji(``),Ta=(e=>{var t=[],n=0,r={},i=e=>{for(var r=0,i=t[r],a=1;a<n;){var o=a+1;r=a,o<n&&t[o]._score<t[a]._score&&(r=o),t[r-1>>1]=t[r],a=1+(r<<1)}for(var s=r-1>>1;r>0&&i._score<t[s]._score;s=(r=s)-1>>1)t[r]=t[s];t[r]=i};return r.add=(e=>{var r=n;t[n++]=e;for(var i=r-1>>1;r>0&&e._score<t[i]._score;i=(r=i)-1>>1)t[r]=t[i];t[r]=e}),r.poll=(e=>{if(n!==0){var r=t[0];return t[0]=t[--n],i(),r}}),r.peek=(e=>{if(n!==0)return t[0]}),r.replaceTop=(e=>{t[0]=e,i()}),r})(),Ea=M`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" aria-label="directory" class="octicon octicon-file-directory"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1z"/></svg>`,Da=M`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" aria-label="file" class="octicon octicon-file"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/></svg>`,Oa=M`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor"><path d="M10.25 2a8.25 8.25 0 0 1 6.34 13.53l5.69 5.69a.749.749 0 0 1-.326 1.275.75.75 0 0 1-.734-.215l-5.69-5.69A8.25 8.25 0 1 1 10.25 2M3.5 10.25a6.75 6.75 0 1 0 13.5 0 6.75 6.75 0 0 0-13.5 0"/></svg>`,ka=M`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"><path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.75.75 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06M3.28 7.78 1.81 9.25l1.47 1.47a.75.75 0 0 1-.018 1.042.75.75 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.042.018.75.75 0 0 1 .018 1.042m8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/></svg>`,Aa=M`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"><path d="M10.336 0c.464 0 .91.184 1.237.513l2.914 2.914c.33.328.513.773.513 1.237v3.587c0 .199-.079.39-.22.53a.747.747 0 0 1-1.06 0 .75.75 0 0 1-.22-.53V6h-2.75c-.464 0-.909-.184-1.237-.513A1.75 1.75 0 0 1 9 4.25V1.5H3.75a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25H7c.199 0 .39.079.53.22a.747.747 0 0 1 0 1.06A.75.75 0 0 1 7 16H3.75c-.464 0-.909-.184-1.237-.513A1.75 1.75 0 0 1 2 14.25V1.75C2 .784 2.784 0 3.75 0Zm.164 4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/><path d="M15.259 10a.75.75 0 0 1 .686.472.75.75 0 0 1-.171.815l-4.557 4.45a.75.75 0 0 1-1.055-.01L8.22 13.778a.754.754 0 0 1 .04-1.02.75.75 0 0 1 1.02-.038l1.42 1.425 4.025-3.932a.75.75 0 0 1 .534-.213"/></svg>`,ja=e=>M`<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" aria-hidden="true" class="${e} h-4 w-4" viewBox="0 0 16 16"><path d="M8.22 2.97a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.042-.018.75.75 0 0 1-.018-1.042l2.97-2.97H3.75a.75.75 0 0 1 0-1.5h7.44L8.22 4.03a.75.75 0 0 1 0-1.06"/></svg>`,Ma=ja(`rotate-180`),Na=ja(),Pa=class extends ei{#e=new AbortController;#t=[];#n=``;get isOpen(){return this.dialog.open}constructor(){super(),this.open=()=>{this.dialog.showModal()},this.close=()=>{this.dialog.close()},this.fileIndex=0,this.filteredFiles=[]}connectedCallback(){super.connectedCallback(),this.#n=document.body.style.overflow,window.addEventListener(`keydown`,this.#o,{signal:this.#e.signal})}disconnectedCallback(){super.disconnectedCallback(),Yi(),this.#e.abort()}willUpdate(e){e.has(`rootModel`)&&(this.#a(),this.#m(``))}updated(e){(e.has(`fileIndex`)||e.has(`filteredFiles`))&&this.#s()}render(){return j`<dialog
95
+ @click=${this.close}
96
+ @toggle=${this.#f}
97
+ aria-labelledby="file-picker-label"
98
+ class="mx-auto my-4 max-w-160 bg-transparent backdrop:bg-gray-950/50 backdrop:backdrop-blur-lg md:w-1/2"
99
+ >
100
+ <div @click=${e=>e.stopPropagation()} class="flex h-fit max-h-132 flex-col rounded-lg bg-gray-200/60 p-4 backdrop-blur-lg">
101
+ <div class="mb-3 flex items-center rounded-sm bg-gray-200/60 p-2 text-gray-800 shadow-lg">
102
+ <div class="mx-2 flex items-center">${Oa}</div>
103
+ <label id="file-picker-label" for="file-picker-input" class="sr-only">Search for a file</label>
104
+ <input
105
+ autocomplete="off"
106
+ id="file-picker-input"
107
+ @input=${this.#p}
108
+ type="search"
109
+ style="box-shadow: none"
110
+ class="mr-2 w-full border-0 border-transparent bg-transparent focus:shadow-none"
111
+ placeholder="Search for a file (Ctrl-K)"
112
+ aria-controls="files"
113
+ />
114
+ </div>
115
+ ${this.#r()}
116
+ </div>
117
+ </dialog>`}#r(){return j`<ul id="files" tabindex="-1" class="flex snap-y flex-col gap-2 overflow-auto" role="listbox" aria-labelledby="file-picker-label">
118
+ ${this.filteredFiles.length===0?j`<li class="text-gray-800">No files found</li>`:Y(this.filteredFiles,e=>e.name,({name:e,file:t,template:n},r)=>{let i=this.#h(t);return j`<li
119
+ class="group snap-start rounded-sm bg-gray-200 text-gray-900 transition-shadow aria-selected:bg-primary-500 aria-selected:text-gray-50 aria-selected:shadow-lg"
120
+ role="option"
121
+ aria-selected=${r===this.fileIndex}
122
+ >
123
+ <a
124
+ tabindex=${r===this.fileIndex?0:-1}
125
+ @click=${this.close}
126
+ class="flex h-full flex-wrap items-center p-2 outline-hidden"
127
+ @mousemove=${()=>this.fileIndex=r}
128
+ href=${di(i,e)}
129
+ >
130
+ <span class="inline-flex" title="File with ${i}s">${this.#i(i)}</span>
131
+ <span class="ms-1">${t.result?.name}</span>
132
+ <span class="mx-2">•</span>
133
+ <span class="text-gray-400 group-aria-selected:text-gray-200">${n??e}</span>
134
+ </a>
135
+ </li>`})}
136
+ </ul>`}#i(e){return e===K.mutant?ka:Aa}#a(){if(!this.rootModel)return;this.#t=[];let e=(t,n=null,r)=>{if(t){if(t.file&&t.name!==r){let e=n?`${n}/${t.name}`:t.name;this.#t.push({name:e,file:t.file,prepared:Ji(e)})}t.childResults.forEach(i=>{n!==r&&n&&t.name?e(i,`${n}/${t.name}`,r):(n===r||!n)&&t.name!==r?e(i,t.name,r):e(i,null,r)})}};e(this.rootModel.systemUnderTestMetrics,null,`All files`),e(this.rootModel.testMetrics,null,`All tests`)}#o=e=>{((e.ctrlKey||e.metaKey)&&e.key===`k`||!this.isOpen&&e.key===`/`)&&this.#d(e),this.isOpen&&(e.key===`ArrowUp`?this.#l():e.key===`ArrowDown`&&this.#c(),e.key===`Enter`&&this.#u())};#s(){this.activeLink?.scrollIntoView({block:`nearest`})}#c(){if(this.fileIndex===this.filteredFiles.length-1){this.fileIndex=0;return}this.fileIndex=Math.min(this.filteredFiles.length-1,this.fileIndex+1)}#l(){if(this.fileIndex===0){this.fileIndex=this.filteredFiles.length-1;return}this.fileIndex=Math.max(0,this.fileIndex-1)}#u(){if(this.filteredFiles.length===0)return;let e=this.filteredFiles[this.fileIndex];window.location.href=di(this.#h(e.file),e.name),this.close()}#d=(e=null)=>{e?.preventDefault(),e?.stopPropagation(),this.isOpen?this.close():this.open()};#f=e=>{e.newState===`closed`?(this.#m(``),this.filePickerInput.value=``,document.body.style.overflow=this.#n):e.newState===`open`?document.body.style.overflow=`hidden`:console.warn(`Unknown toggle state in file-picker:`,e.newState)};#p=e=>{this.isOpen&&this.#m(e.target.value)};#m(e){e?this.filteredFiles=Ki(e,this.#t,{key:`prepared`,threshold:.3,limit:500}).map(e=>({file:e.obj.file,name:e.obj.name,template:e.highlight(e=>j`<mark class="bg-inherit text-primary-500 group-aria-selected:text-primary-50 group-aria-selected:underline">${e}</mark>`)})):this.filteredFiles=this.#t,this.fileIndex=0}#h(e){return e instanceof at?K.test:K.mutant}};G([I({attribute:!1})],Pa.prototype,`rootModel`,void 0),G([L()],Pa.prototype,`filteredFiles`,void 0),G([L()],Pa.prototype,`fileIndex`,void 0),G([Re(`dialog`,!0)],Pa.prototype,`dialog`,void 0),G([Re(`#file-picker-input`,!0)],Pa.prototype,`filePickerInput`,void 0),G([Re(`[aria-selected="true"] a`)],Pa.prototype,`activeLink`,void 0),Pa=G([F(`mte-file-picker`)],Pa);var Fa=class extends ei{get rootName(){switch(this.view){case K.mutant:return`All files`;case K.test:return`All tests`}}render(){return j`<nav class="my-4 flex rounded-md bg-primary-100 p-3 text-gray-700" aria-label="Breadcrumb">
137
+ <ol class="inline-flex items-center">
138
+ ${this.path&&this.path.length>0?this.#n(this.rootName,[]):this.#t(this.rootName)}
139
+ ${this.#e()}
140
+ </ol>
141
+ ${this.#r()}
142
+ </nav>`}#e(){if(this.path){let e=this.path;return Y(e,e=>e,(t,n)=>n===e.length-1?this.#t(t):this.#n(t,e.slice(0,n+1)))}}#t(e){return j`<li aria-current="page">
143
+ <span class="ml-1 text-sm font-medium text-gray-800 md:ml-2">${e}</span>
144
+ </li>`}#n(e,t){return j`<li class="after:text-gray-800 after:content-['/'] md:after:pl-1">
145
+ <a
146
+ href=${di(this.view,...t)}
147
+ class="ml-1 text-sm font-medium text-primary-800 underline hover:text-gray-900 hover:underline md:ml-2"
148
+ >${e}</a
149
+ >
150
+ </li>`}#r(){return j`<button @click=${()=>this.#i()} class="ml-auto cursor-pointer" title="Open file picker (Ctrl-K)"
151
+ >${Oa}</button
152
+ >`}#i(){this.blur(),this.renderRoot.querySelector(`button`)?.blur(),this.dispatchEvent(H(`mte-file-picker-open`,void 0))}};G([I({type:Array,attribute:!1})],Fa.prototype,`path`,void 0),G([I()],Fa.prototype,`view`,void 0),Fa=G([F(`mte-breadcrumb`)],Fa);var Ia=class extends q{updated(e){e.has(`filters`)&&this.#t()}#e(e,t){e.enabled=t,this.#t()}#t(){this.dispatchEvent(H(`filters-changed`,this.filters.filter(({enabled:e})=>e).map(({status:e})=>e)))}#n=e=>{e.stopPropagation(),this.dispatchEvent(H(`next`,void 0,{bubbles:!0,composed:!0}))};#r=e=>{e.stopPropagation(),this.dispatchEvent(H(`previous`,void 0,{bubbles:!0,composed:!0}))};render(){return j`<div class="sticky top-offset z-10 mb-1 flex flex-row gap-5 bg-white py-6 pt-7">
153
+ <div class="flex items-center gap-2">
154
+ ${this.#i(this.#r,Ma,`Previous`,`Select previous mutant`)}
155
+ ${this.#i(this.#n,Na,`Next`,`Select next mutant`)}
156
+ </div>
157
+
158
+ ${Y(this.filters??[],e=>e.status,e=>j`<div class="flex items-center gap-2 last:mr-12" data-status=${e.status.toString()}>
159
+ <input
160
+ ?checked=${e.enabled}
161
+ id="filter-${e.status}"
162
+ aria-describedby="status-description"
163
+ type="checkbox"
164
+ .value=${e.status.toString()}
165
+ @input=${t=>this.#e(e,t.target.checked)}
166
+ class="h-5 w-5 shrink-0 rounded-sm bg-gray-100 ring-offset-gray-200! transition-colors checked:bg-primary-600 focus:ring-2 focus:ring-primary-500 focus:outline-hidden"
167
+ />
168
+
169
+ <label
170
+ for="filter-${e.status}"
171
+ class="${this.#a(e.context)} rounded-md px-2.5 py-0.5 text-sm font-medium hover:cursor-pointer"
172
+ >
173
+ ${e.label} (${e.count})
174
+ </label>
175
+ </div>`)}
176
+ </div>`}#i(e,t,n,r){return j`<button
177
+ title=${n}
178
+ @click=${e}
179
+ type="button"
180
+ class="inline-flex items-center rounded-sm bg-primary-600 p-1 text-center text-white hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:outline-hidden"
181
+ >${t}
182
+ <span class="sr-only">${r}</span>
183
+ </button>`}#a(e){switch(e){case`success`:return`bg-green-100 text-green-800`;case`warning`:return`bg-yellow-100 text-yellow-800`;case`danger`:return`bg-red-100 text-red-800`;case`caution`:return`bg-orange-100 text-orange-800`;default:return`bg-gray-100 text-gray-800`}}};G([I({type:Array})],Ia.prototype,`filters`,void 0),Ia=G([F(`mte-state-filter`)],Ia);var La=`:host{--theme-d:1.5em;--theme-s:1.2em;--theme-p:.15em;--theme-g:.06em;--theme-width:2.9em;--poly:polygon(44.1337% 12.9617%, 50% 0%, 55.8663% 12.9617%, 59.7057% 13.7778%, 63.4388% 14.9907%, 67.0246% 16.5873%, 79.3893% 9.54915%, 76.5165% 23.4835%, 79.143% 26.4005%, 81.4502% 29.576%, 83.4127% 32.9754%, 97.5528% 34.5492%, 87.0383% 44.1337%, 87.4486% 48.0374%, 87.4486% 51.9626%, 87.0383% 55.8663%, 97.5528% 65.4508%, 83.4127% 67.0246%, 81.4502% 70.424%, 79.143% 73.5995%, 76.5165% 76.5165%, 79.3893% 90.4508%, 67.0246% 83.4127%, 63.4388% 85.0093%, 59.7057% 86.2222%, 55.8663% 87.0383%, 50% 100%, 44.1337% 87.0383%, 40.2943% 86.2222%, 36.5612% 85.0093%, 32.9754% 83.4127%, 20.6107% 90.4508%, 23.4835% 76.5165%, 20.857% 73.5995%, 18.5499% 70.424%, 16.5873% 67.0246%, 2.44717% 65.4508%, 12.9617% 55.8663%, 12.5514% 51.9626%, 12.5514% 48.0374%, 12.9617% 44.1337%, 2.44717% 34.5492%, 16.5873% 32.9754%, 18.5499% 29.576%, 20.857% 26.4005%, 23.4835% 23.4835%, 20.6107% 9.54915%, 32.9754% 16.5873%, 36.5612% 14.9907%, 40.2943% 13.7778%)}#darkTheme{position:absolute;right:100vw}#darkTheme+label{--i:0;--j:calc(1 - var(--i));grid-gap:var(--theme-p) var(--theme-g);padding:var(--theme-p);height:var(--theme-d);border-radius:calc(.5 * var(--theme-s) + var(--theme-p));background:hsl(199, 98%, calc(var(--j) * 48%));color:#0000;-webkit-user-select:none;user-select:none;cursor:pointer;transition:all .3s;display:grid;overflow:hidden}#darkTheme+label:before,#darkTheme+label:after{width:var(--theme-s);height:var(--theme-s);content:"";transition:inherit}#darkTheme+label:before{transform-origin:20% 20%;transform:translate(calc(var(--i) * (100% + var(--theme-g)))) scale(calc(1 - var(--i) * .8));-webkit-clip-path:var(--poly);clip-path:var(--poly);background:#ff0}#darkTheme+label:after{transform:translatey(calc(var(--i) * (-130% - var(--theme-p))));background:radial-gradient(circle at 19% 19%,#0000 41%,#fff 43%);border-radius:50%;grid-column:2}#darkTheme:checked+label{--i:1}.check-box-container{width:var(--theme-width)}`,Ra=class extends ei{#e=e=>{let t=e.target.checked;this.dispatchEvent(H(`theme-switch`,t?`dark`:`light`))};static{this.styles=[U,s(La)]}render(){return j`<div class="check-box-container" @click=${e=>e.stopPropagation()}>
184
+ <input type="checkbox" @click=${this.#e} ?checked=${this.theme===`dark`} id="darkTheme" />
185
+ <label for="darkTheme">Dark</label>
186
+ </div>`}};G([I()],Ra.prototype,`theme`,void 0),Ra=G([F(`mte-theme-switch`)],Ra);var za=({hasDetail:e,mode:t},n)=>j`<mte-drawer
187
+ class="fixed bottom-0 z-10 container rounded-t-3xl bg-gray-200/60 shadow-xl backdrop-blur-lg motion-safe:transition-[height,max-width] motion-safe:duration-200"
188
+ ?has-detail=${e}
189
+ mode=${t}
190
+ >
191
+ ${n}
192
+ </mte-drawer>`,Ba=e=>`${e.name}${e.sourceFile&&e.location?` (${pi(e)})`:``}`,Va=e=>j`<span class="whitespace-pre-wrap">${e}</span>`,Ha=class extends q{constructor(){super(),this.mode=`closed`}render(){return za({hasDetail:!!(this.mutant?.killedByTests?.length||this.mutant?.coveredByTests?.length||this.mutant?.statusReason),mode:this.mode},R(this.mutant,e=>j`<span class="align-middle text-lg" slot="header"
193
+ >${li(e.status)} ${e.mutatorName} ${e.status}
194
+ (${e.location.start.line}:${e.location.start.column})</span
195
+ >
196
+ <span slot="summary">${this.#e()}</span>
197
+ <span slot="detail" class="block">${this.#t()}</span>`))}#e(){return ri(j`${R(this.mutant?.killedByTests?.[0],e=>ni(j`${W(`🎯`,`killed`)} Killed by:
198
+ ${e.name}${this.mutant.killedByTests.length>1?`(and ${this.mutant.killedByTests.length-1} more)`:``}`))}
199
+ ${R(this.mutant?.static,()=>ni(j`${W(`🗿`,`static`)} Static mutant`))}
200
+ ${R(this.mutant?.coveredByTests,e=>ni(j`${W(`☂️`,`umbrella`)} Covered by ${e.length}
201
+ test${fi(e)}${this.mutant?.status===`Survived`?` (yet still survived)`:``}`))}
202
+ ${R(this.mutant?.statusReason?.trim(),e=>ni(j`${W(`🕵️`,`spy`)} ${Va(e)}`,`Reason for the ${this.mutant.status} status`))}
203
+ ${R(this.mutant?.description,e=>ni(j`${W(`📖`,`book`)} ${Va(e)}`))}`)}#t(){return j`<ul class="mr-2 mb-6">
204
+ ${bi(this.mutant?.killedByTests,e=>ti(`This mutant was killed by this test`,j`${W(`🎯`,`killed`)} ${Ba(e)}`))}
205
+ ${bi(this.mutant?.coveredByTests?.filter(e=>!this.mutant?.killedByTests?.includes(e)),e=>ti(`This mutant was covered by this test`,j`${W(`☂️`,`umbrella`)} ${Ba(e)}`))}
206
+ </ul>`}};G([I({attribute:!1})],Ha.prototype,`mutant`,void 0),G([I({reflect:!0})],Ha.prototype,`mode`,void 0),Ha=G([F(`mte-drawer-mutant`)],Ha);var Ua=class extends q{constructor(){super(),this.drawerMode=`closed`}#e=()=>{this.drawerMode=`closed`};#t=e=>{this.selectedMutant=e.detail.mutant,this.drawerMode=e.detail.selected?`half`:`closed`};updated(e){e.has(`result`)&&!this.result.file&&(this.drawerMode=`closed`)}render(){return j`<main class="pb-drawer-half-open" @click=${this.#e}>
207
+ <mte-metrics-table .columns=${Wa} .currentPath=${this.path} .thresholds=${this.thresholds} .model=${this.result}> </mte-metrics-table>
208
+ ${R(this.result.file,e=>j`<mte-file @mutant-selected=${this.#t} .model=${e}></mte-file>`)}
209
+ </main>
210
+ <mte-drawer-mutant mode=${this.drawerMode} .mutant=${this.selectedMutant}></mte-drawer-mutant>`}};G([L()],Ua.prototype,`drawerMode`,void 0),G([I({attribute:!1})],Ua.prototype,`selectedMutant`,void 0),G([I({attribute:!1})],Ua.prototype,`result`,void 0),G([I({attribute:!1,reflect:!1})],Ua.prototype,`thresholds`,void 0),G([I({attribute:!1,reflect:!1})],Ua.prototype,`path`,void 0),Ua=G([F(`mte-mutant-view`)],Ua);var Wa=[{key:`mutationScore`,label:`Of total`,tooltip:`The percentage of mutants that were detected. The higher, the better!`,category:`percentage`,group:`Mutation score`},{key:`mutationScoreBasedOnCoveredCode`,label:`Of covered`,tooltip:`Mutation score based on only the code covered by tests`,category:`percentage`,group:`Mutation score`},{key:`killed`,label:`Killed`,tooltip:`At least one test failed while these mutants were active. This is what you want!`,category:`number`},{key:`survived`,label:`Survived`,tooltip:`All tests passed while these mutants were active. You're missing a test for them.`,category:`number`},{key:`timeout`,label:`Timeout`,tooltip:`Running the tests while these mutants were active resulted in a timeout. For example, an infinite loop.`,category:`number`},{key:`noCoverage`,label:`No coverage`,tooltip:`These mutants aren't covered by one of your tests and survived as a result.`,category:`number`},{key:`ignored`,label:`Ignored`,tooltip:`These mutants weren't tested because they are ignored. Either by user action, or for another reason.`,category:`number`},{key:`runtimeErrors`,label:`Runtime errors`,tooltip:`Running tests when these mutants are active resulted in an error (rather than a failed test). For example: an out of memory error.`,category:`number`},{key:`compileErrors`,label:`Compile errors`,tooltip:`Mutants that caused a compile error.`,category:`number`},{key:`totalDetected`,label:`Detected`,tooltip:`The number of mutants detected by your tests (killed + timeout).`,category:`number`,width:`large`,isBold:!0},{key:`totalUndetected`,label:`Undetected`,tooltip:`The number of mutants that are not detected by your tests (survived + no coverage).`,category:`number`,width:`large`,isBold:!0},{key:`totalMutants`,label:`Total`,tooltip:`All mutants (except runtimeErrors + compileErrors)`,category:`number`,width:`large`,isBold:!0}],Ga=class extends q{constructor(){super(),this.drawerMode=`closed`}#e=()=>{this.drawerMode=`closed`};#t=e=>{this.selectedTest=e.detail.test,this.drawerMode=e.detail.selected?`half`:`closed`};updated(e){e.has(`result`)&&!this.result.file&&(this.drawerMode=`closed`)}render(){return j`<main class="pb-drawer-half-open" @click=${this.#e}>
211
+ <mte-metrics-table .columns=${Ka} .currentPath=${this.path} .model=${this.result}> </mte-metrics-table>
212
+ ${R(this.result.file,e=>j`<mte-test-file @test-selected=${this.#t} .model=${e}></mte-test-file>`)}
213
+ </main>
214
+ <mte-drawer-test mode=${this.drawerMode} .test=${this.selectedTest}></mte-drawer-test>`}};G([L()],Ga.prototype,`drawerMode`,void 0),G([I({attribute:!1})],Ga.prototype,`result`,void 0),G([I({attribute:!1,reflect:!1})],Ga.prototype,`path`,void 0),G([I({attribute:!1})],Ga.prototype,`selectedTest`,void 0),Ga=G([F(`mte-test-view`)],Ga);var Ka=[{key:`killing`,label:`Killing`,tooltip:`These tests killed at least one mutant`,width:`normal`,category:`number`},{key:`covering`,label:`Covering`,tooltip:`These tests are covering at least one mutant, but not killing any of them.`,width:`normal`,category:`number`},{key:`notCovering`,label:`Not Covering`,tooltip:`These tests were not covering a mutant (and thus not killing any of them).`,width:`normal`,category:`number`},{key:`total`,label:`Total tests`,width:`large`,category:`number`,isBold:!0}],qa=class extends q{constructor(){super(),this.currentPath=[],this.thresholds={high:80,low:60}}#e=!1;willUpdate(e){e.has(`columns`)&&(this.#e=this.columns.some(e=>e.category===`percentage`))}render(){return R(this.model,e=>j`<div class="overflow-x-auto rounded-md border border-gray-200">
215
+ <table class="w-full table-auto text-left text-sm">${this.#t()}${this.#r(e)} </table>
216
+ </div>`)}#t(){let e=this.columns.filter(e=>e.group!==`Mutation score`),t=this.columns.filter(e=>e.group===`Mutation score`);return j`<thead class="border-b border-gray-200 text-center text-sm">
217
+ <tr>
218
+ <th rowspan="2" scope="col" class="px-4 py-4">
219
+ <div class="flex items-center justify-around">
220
+ <span>File / Directory</span
221
+ ><a
222
+ href="https://stryker-mutator.io/docs/mutation-testing-elements/mutant-states-and-metrics"
223
+ target="_blank"
224
+ class="info-icon float-right"
225
+ title="What does this all mean?"
226
+ >${W(`ℹ`,`info icon`)}</a
227
+ >
228
+ </div>
229
+ </th>
230
+ ${t.length>0?j`<th colspan="4" class="px-2 even:bg-gray-100">Mutation Score</th>`:``}
231
+ ${Y(e,e=>e.key,e=>this.#n(e))}
232
+ </tr>
233
+ <tr>
234
+ ${Y(t,e=>e.key,e=>this.#n(e))}
235
+ </tr>
236
+ </thead>`}#n(e){let t=`tooltip-${e.key.toString()}`,n=e.tooltip?j`<mte-tooltip title=${e.tooltip} id=${t}>${e.label}</mte-tooltip>`:j`<span id=${t}>${e.label}</span>`;return e.group?j`<th colspan="2" class="bg-gray-200 px-2"> ${n} </th>`:j`<th rowspan="2" class="w-24 px-2 even:bg-gray-100 2xl:w-28">
237
+ <div class="inline-block">${n}</div>
238
+ </th>`}#r(e){return j`<tbody class="divide-y divide-gray-200">${this.#i(e.name,e)} ${R(!e.file,()=>Y(e.childResults,e=>e.name,e=>{let t=[e.name];for(;!e.file&&e.childResults.length===1;)e=e.childResults[0],t.push(e.name);return this.#i(t.join(`/`),e,...this.currentPath,...t)}))}</tbody>`}#i(e,t,...n){return j`<tr title=${t.name} class="group hover:bg-gray-200">
239
+ <td class="font-semibold">
240
+ <div class="flex items-center justify-start">
241
+ <mte-file-icon file-name=${t.name} ?file=${t.file} class="mx-1 flex items-center"></mte-file-icon> ${n.length>0?j`<a class="mr-auto inline-block w-full py-4 pr-2 hover:text-primary-on hover:underline" href=${di(...n)}>${e}</a>`:j`<span class="py-4">${t.name}</span>`}
242
+ </div>
243
+ </td>
244
+ ${Y(this.columns,e=>e.key,e=>this.#a(e,t.metrics))}
245
+ </tr>`}#a(e,t){let n=t[e.key],r=this.#e?`odd:bg-gray-100`:`even:bg-gray-100`;if(e.category===`percentage`){let t=!isNaN(n),i=this.#o(n),a=this.#s(n),o=n.toFixed(2),s=`width: ${n}%`;return j`<td class="bg-gray-100 px-4 py-4 group-hover:bg-gray-200!">
246
+ ${t?j`<div class="h-3 w-full min-w-[24px] rounded-full bg-gray-300">
247
+ <div
248
+ class="${i} h-3 rounded-full pl-1 transition-all"
249
+ role="progressbar"
250
+ aria-valuenow=${o}
251
+ aria-valuemin="0"
252
+ aria-valuemax="100"
253
+ aria-describedby="tooltip-mutationScore"
254
+ title=${e.label}
255
+ style=${s}
256
+ ></div>
257
+ </div>`:j`<span class="text-light-muted font-bold">N/A</span>`}
258
+ </td>
259
+ <td class="${a} ${r} w-12 pr-2 text-center font-bold group-hover:bg-gray-200!"
260
+ >${R(t,()=>j`<span class="transition-colors">${o}</span>`)}</td
261
+ >`}return j`<td
262
+ class="${Yr({"font-bold":e.isBold??!1,[r]:!0})} py-4 text-center group-hover:bg-gray-200!"
263
+ aria-describedby=${`tooltip-${e.key.toString()}`}
264
+ >${n}</td
265
+ >`}#o(e){return!isNaN(e)&&this.thresholds?e<this.thresholds.low?`bg-red-600 text-gray-200`:e<this.thresholds.high?`bg-yellow-400`:`bg-green-600 text-gray-200`:`bg-cyan-600`}#s(e){return!isNaN(e)&&this.thresholds?e<this.thresholds.low?`text-red-700`:e<this.thresholds.high?`text-yellow-600`:`text-green-700`:``}};G([I({attribute:!1})],qa.prototype,`model`,void 0),G([I({attribute:!1})],qa.prototype,`currentPath`,void 0),G([I({type:Array})],qa.prototype,`columns`,void 0),G([I({attribute:!1})],qa.prototype,`thresholds`,void 0),qa=G([F(`mte-metrics-table`)],qa);var Ja=`#report-code-block{background:var(--prism-background);border:1px solid var(--prism-border);overflow:auto visible}.line-numbers{counter-reset:mte-line-number}.line .line-number{text-align:right;color:var(--mut-line-number);counter-increment:mte-line-number;padding:0 10px 0 15px}.line .line-number:before{content:counter(mte-line-number)}.line-marker:before{content:" ";padding:0 5px}.Killing{--mut-test-dot-color:var(--color-green-700)}.Covering{--mut-test-dot-color:var(--color-amber-400)}.NotCovering{--mut-test-dot-color:var(--color-orange-500)}svg.test-dot{fill:var(--mut-test-dot-color)}`,Ya=class extends q{static{this.styles=[$r,U,s(Ja)]}#e;constructor(){super(),this.filters=[],this.lines=[],this.enabledStates=[],this.tests=[],this.#e=new AbortController}connectedCallback(){super.connectedCallback(),window.addEventListener(`keydown`,this.#t,{signal:this.#e.signal})}disconnectedCallback(){this.#e.abort(),super.disconnectedCallback()}#t=e=>{e.key===`Escape`&&this.#l()};#n=e=>{this.enabledStates=e.detail,this.selectedTest&&!this.enabledStates.includes(this.selectedTest.status)&&this.#r(this.selectedTest)};#r(e){this.#f(e),this.selectedTest===e?(this.selectedTest=void 0,this.dispatchEvent(H(`test-selected`,{selected:!1,test:e}))):(this.selectedTest&&this.#f(this.selectedTest),this.selectedTest=e,this.dispatchEvent(H(`test-selected`,{selected:!0,test:e})),mi(this.renderRoot.querySelector(`[data-test-id="${e.id}"]`)))}#i=()=>{let e=this.selectedTest?(this.tests.findIndex(({id:e})=>e===this.selectedTest.id)+1)%this.tests.length:0;this.#o(this.tests[e])};#a=()=>{let e=this.selectedTest?(this.tests.findIndex(({id:e})=>e===this.selectedTest.id)+this.tests.length-1)%this.tests.length:this.tests.length-1;this.#o(this.tests[e])};#o(e){e&&this.#r(e)}render(){return j`<mte-state-filter
266
+ @next=${this.#i}
267
+ @previous=${this.#a}
268
+ .filters=${this.filters}
269
+ @filters-changed=${this.#n}
270
+ ></mte-state-filter>
271
+ ${this.#s()} ${this.#c()}`}#s(){let e=this.tests.filter(e=>!e.location);return R(e.length,()=>j`<ul class="max-w-6xl">
272
+ ${Y(e,e=>e.id,e=>j`<li class="my-3">
273
+ <button
274
+ class="w-full rounded-sm p-3 text-left hover:bg-gray-100 active:bg-gray-200"
275
+ type="button"
276
+ data-active=${this.selectedTest===e}
277
+ data-test-id=${e.id}
278
+ @click=${t=>{t.stopPropagation(),this.#r(e)}}
279
+ >${ci(e.status)} ${e.name} [${e.status}]
280
+ </button>
281
+ </li>`)}
282
+ </ul>`,()=>P)}#c(){return R(this.model?.source,()=>{let e=Map.groupBy(this.tests.filter(e=>Ge(e.location)),e=>e.location.start.line),t=this.#u(Array.from(e.entries()).filter(([e])=>e>this.lines.length).flatMap(([,e])=>e));return j`<pre
283
+ id="report-code-block"
284
+ @click=${this.#l}
285
+ class="line-numbers flex rounded-md p-1"
286
+ ><code class="flex language-${Ci(this.model.name)}">
287
+ <table>
288
+ ${bi(this.lines,(n,r)=>{let i=r+1;return Ni(n,Mi(this.#u(e.get(i)),this.lines.length===i?t:P))})}</table></code></pre>`},()=>P)}#l=()=>{this.selectedTest&&this.#r(this.selectedTest)};#u(e){return R(e?.length,()=>Y(e,e=>e.id,e=>M`<svg
289
+ data-test-id="${e.id}"
290
+ class="test-dot ${this.selectedTest?.id===e.id?`selected`:``} ${e.status} mx-0.5 cursor-pointer"
291
+ @click="${t=>{t.stopPropagation(),this.#r(e)}}"
292
+ height="11"
293
+ width="11"
294
+ >
295
+ <title>${Xa(e)}</title>
296
+ ${this.selectedTest===e?Ri:zi}
297
+ </svg>`),()=>P)}reactivate(){super.reactivate(),this.#d()}willUpdate(e){e.has(`model`)&&this.#d(),(e.has(`model`)||e.has(`enabledStates`))&&this.model&&(this.tests=this.model.tests.filter(e=>this.enabledStates.includes(e.status)).sort((e,t)=>e.location&&t.location?Oi(e.location.start,t.location.start)?1:-1:this.model.tests.indexOf(e)-this.model.tests.indexOf(t)))}#d(){if(!this.model)return;let e=this.model;this.filters=[z.Killing,z.Covering,z.NotCovering].filter(t=>e.tests.some(e=>e.status===t)).map(t=>({enabled:!0,count:e.tests.filter(e=>e.status===t).length,status:t,label:j`${ci(t)} ${t}`,context:si(t)})),this.model.source&&(this.lines=Ti(wi(this.model.source,this.model.name)))}#f(e){Bi(this.renderRoot,`data-test-id`,e.id)}};G([I({attribute:!1})],Ya.prototype,`model`,void 0),G([L()],Ya.prototype,`filters`,void 0),G([L()],Ya.prototype,`lines`,void 0),G([L()],Ya.prototype,`enabledStates`,void 0),G([L()],Ya.prototype,`selectedTest`,void 0),G([L()],Ya.prototype,`tests`,void 0),Ya=G([F(`mte-test-file`)],Ya);function Xa(e){return`${e.name} (${e.status})`}var Za=e=>j`<code>${e.getMutatedLines()}</code> (${pi(e)})`,Qa=class extends q{constructor(){super(),this.mode=`closed`}render(){return za({hasDetail:!!(this.test?.killedMutants?.length||this.test?.coveredMutants?.length),mode:this.mode},R(this.test,e=>j`<span class="align-middle text-lg" slot="header"
298
+ >${ci(e.status)} ${e.name} [${e.status}]
299
+ ${R(e.location,e=>j`(${e.start.line}:${e.start.column})`)}</span
300
+ >
301
+ <span slot="summary">${this.#e()}</span>
302
+ <span class="block" slot="detail">${this.#t()}</span>`))}#e(){return ri(j`${R(this.test?.killedMutants?.[0],e=>ni(j`${W(`🎯`,`killed`)} Killed:
303
+ ${Za(e)}${this.test.killedMutants.length>1?j` (and ${this.test.killedMutants.length-1} more)`:``}`))}
304
+ ${R(this.test?.coveredMutants,e=>ni(j`${W(`☂️`,`umbrella`)} Covered ${e.length}
305
+ mutant${fi(e)}${this.test?.status===z.Covering?` (yet didn't kill any of them)`:``}`))}`)}#t(){return j`<ul class="mr-2 mb-6">
306
+ ${bi(this.test?.killedMutants,e=>ti(`This test killed this mutant`,j`${W(`🎯`,`killed`)} ${Za(e)}`))}
307
+ ${bi(this.test?.coveredMutants?.filter(e=>!this.test?.killedMutants?.includes(e)),e=>ti(`This test covered this mutant`,j`${W(`☂️`,`umbrella`)} ${Za(e)}`))}
308
+ </ul>`}};G([I({attribute:!1})],Qa.prototype,`test`,void 0),G([I({reflect:!0})],Qa.prototype,`mode`,void 0),Qa=G([F(`mte-drawer-test`)],Qa);var $a=`svg{width:20px}svg.cs{fill:var(--mut-file-csharp-color)}svg.html{fill:var(--mut-file-html-color)}svg.java{fill:var(--mut-file-java-color)}svg.javascript{fill:var(--mut-file-js-color)}svg.scala{fill:var(--mut-file-scala-color)}svg.typescript{fill:var(--mut-file-ts-color)}svg.php{fill:var(--mut-file-php-color)}svg.vue{fill:var(--mut-file-vue-color)}svg.octicon{fill:var(--mut-octicon-icon-color)}svg.javascript.test{fill:var(--mut-file-js-test-color)}svg.typescript.test{fill:var(--mut-file-ts-test-color)}svg.gherkin{fill:var(--mut-file-gherkin-color)}svg.svelte{fill:var(--mut-file-svelte-color)}svg.rust{fill:var(--mut-file-rust-color)}svg.python{fill:var(--mut-file-python-color)}`,eo=class extends ei{static{this.styles=[s($a)]}get#e(){return Ci(this.fileName)}get#t(){let e=this.fileName.substr(0,this.fileName.lastIndexOf(`.`)).toLowerCase();return e.endsWith(`spec`)||e.endsWith(`test`)||e.endsWith(`unit`)}get#n(){return Yr({[this.#e?.toString()??`unknown`]:this.file,test:this.#t})}render(){if(!this.file)return Ea;if(!this.#e)return Da;switch(this.#e){case X.csharp:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="cs" class="${this.#n}" viewBox="0 0 32 32"><path d="M7.1 15.9c0-1.3.2-2.4.6-3.4s.9-1.8 1.6-2.5 1.5-1.2 2.4-1.6 1.9-.5 2.9-.5 1.9.2 2.7.6 1.5.9 2 1.4l-2.2 2.5c-.4-.3-.7-.6-1.1-.7s-.8-.3-1.4-.3c-.5 0-.9.1-1.3.3s-.8.5-1.1.9-.5.8-.7 1.4-.3 1.2-.3 1.9c0 1.5.3 2.6 1 3.3.7.8 1.5 1.2 2.6 1.2.5 0 1-.1 1.4-.3s.8-.5 1.1-.9l2.2 2.5c-.7.8-1.4 1.3-2.2 1.7q-1.2.6-2.7.6c-1.5 0-2-.2-2.9-.5S10 22.7 9.3 22s-1.1-1.7-1.5-2.7c-.5-.9-.7-2.1-.7-3.4"/><path d="M21.8 17.1h-1l-.4 2.4h-1.2l.4-2.4h-1.2V16h1.5l.2-1.6h-1.3v-1.1h1.5l.4-2.4h1.2l-.4 2.4h1l.4-2.4h1.2l-.4 2.4H25v1.1h-1.6l-.2 1.6h1.3v1.1h-1.6l-.4 2.4h-1.2c0 .1.5-2.4.5-2.4m-.8-1h1l.2-1.6h-1z"/></svg>`;case X.html:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="html" class="${this.#n}" viewBox="0 0 32 32"><path d="m8 15 6-5.6V12l-4.5 4 4.5 4v2.6L8 17zm16 2.1-6 5.6V20l4.6-4-4.6-4V9.3l6 5.6z"/></svg>`;case X.java:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="java" class="${this.#n}" viewBox="0 0 32 32"><path d="M22.003 18.236c-.023.764.018 1.78-.282 2.64a5.76 5.76 0 0 1-1.348 2.304 6.6 6.6 0 0 1-2.19 1.46c-.825.3-1.453.36-2.585.36s-2.135-.116-3.146-.528a6.9 6.9 0 0 1-2.472-1.91l2.022-2.584a4.4 4.4 0 0 0 1.517 1.236q.9.45 1.967.449 1.404 0 2.19-.899.787-.955.787-2.809V7h3.54z"/></svg>`;case X.javascript:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="js" class="${this.#n}" viewBox="0 0 32 32"><path d="M11.4 10h2.7v7.6c0 3.4-1.6 4.6-4.3 4.6-.6 0-1.5-.1-2-.3l.3-2.2c.4.2.9.3 1.4.3 1.1 0 1.9-.5 1.9-2.4zm5.1 9.2c.7.4 1.9.8 3 .8 1.3 0 1.9-.5 1.9-1.3s-.6-1.2-2-1.7c-2-.7-3.3-1.8-3.3-3.6 0-2.1 1.7-3.6 4.6-3.6 1.4 0 2.4.3 3.1.6l-.6 2.2c-.5-.2-1.3-.6-2.5-.6s-1.8.5-1.8 1.2c0 .8.7 1.1 2.2 1.7 2.1.8 3.1 1.9 3.1 3.6 0 2-1.6 3.7-4.9 3.7-1.4 0-2.7-.4-3.4-.7z"/></svg>`;case X.typescript:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="ts" class="${this.#n}" viewBox="0 0 32 32"><path d="M15.6 11.8h-3.4V22H9.7V11.8H6.3V10h9.2v1.8zm7.7 7.1c0-.5-.2-.8-.5-1.1s-.9-.5-1.7-.8q-2.1-.6-3.3-1.5c-.7-.6-1.1-1.3-1.1-2.3s.4-1.8 1.3-2.4c.8-.6 1.9-.9 3.2-.9s2.4.4 3.2 1.1 1.2 1.6 1.2 2.6h-2.3c0-.6-.2-1-.6-1.4-.4-.3-.9-.5-1.6-.5-.6 0-1.1.1-1.5.4s-.5.7-.5 1.1.2.7.6 1 1 .5 2 .8q1.95.6 3 1.5c.7.6 1 1.4 1 2.4s-.4 1.9-1.2 2.4c-.8.6-1.9.9-3.2.9s-2.5-.3-3.4-1-1.5-1.6-1.4-2.9h2.4c0 .7.2 1.2.7 1.6.4.3 1.1.5 1.8.5s1.2-.1 1.5-.4c.2-.3.4-.7.4-1.1"/></svg>`;case X.scala:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="scala" class="${this.#n}" viewBox="0 0 32 32"><path d="M21.6 7v4.2c-.1.1-.1.2-.2.2-.3.3-.7.5-1.1.6-.9.3-1.9.5-2.8.7-1.6.3-3.1.5-4.7.7-.8.1-1.6.2-2.4.4V9.6c.1-.1.2-.1.4-.1 1.2-.2 2.5-.4 3.8-.5 1.9-.3 3.8-.5 5.6-1.1.5-.2 1.1-.4 1.4-.9m0 5.6v4.2l-.2.2c-.5.4-1.1.6-1.6.8-.8.2-1.6.4-2.4.5-1 .2-1.9.3-2.9.5-1.4.2-2.7.3-4.1.6v-4.2c.1-.1.2-.1.3-.1 1.7-.2 3.4-.5 5.1-.7 1.4-.2 2.9-.5 4.3-.9.6-.2 1.1-.4 1.5-.9M10.5 25h-.1v-4.2c.1-.1.2-.1.3-.1 1.2-.2 2.3-.3 3.5-.5 2-.3 3.9-.5 5.8-1.1.6-.2 1.2-.4 1.6-.9v4.2c-.1.2-.3.3-.5.5-.6.3-1.2.5-1.9.7-1.2.3-2.5.5-3.7.7-1.3.2-2.6.4-3.9.5-.4 0-.7.1-1.1.2"/></svg>`;case X.php:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="php" class="${this.#n}" viewBox="0 0 32 32"><path d="M12.7 19.7c-.1-.6-.4-1.1-1-1.3-.2-.1-.5-.3-.7-.4-.3-.1-.6-.2-.8-.3s-.4 0-.6.2c-.1.2 0 .4.1.5.1.2.2.3.4.5.2.3.4.5.7.8.2.3.4.5.3.9-.1.7-.4 1.4-.9 1.9-.1.1-.2.1-.2.1-.3 0-.7-.2-.9-.4-.3-.3-.2-.6.1-.8.1 0 .2-.1.2-.2.2-.2.3-.4.2-.7-.1-.1-.1-.2-.2-.3-.4-.4-.9-.8-1.4-1.2-1.3-1-1.9-2.2-2-3.6-.1-1.6.3-3.1 1.1-4.5.3-.5.7-1 1.3-1.3.4-.2.8-.3 1.2-.4 1.1-.3 2.3-.5 3.5-.3 1 .2 1.8.7 2.1 1.7.2.7.3 1.3.2 2-.1 1.4-1.2 2.6-2.5 3-.6.2-.9.1-1.2-.4-.2-.3-.5-.7-.7-1.1V14c0-.1-.1-.1-.1-.2.1.6.2 1.2.5 1.7.2.3.4.5.8.5 1.3.1 2.3-.3 3.1-1.3.8-1.1 1-2.4.8-3.8 0-.3-.1-.5-.2-.8 0-.2 0-.3.2-.4.1 0 .2 0 .2-.1 1-.2 2.1-.3 3.1-.2 1.2.1 2.3.4 3.3 1.1 1.6 1 2.6 2.5 3.1 4.3.1.3.1.5.1.8 0 .2-.1.2-.3.1s-.3-.3-.4-.4-.2-.3-.3-.4-.2-.1-.2 0-.1.2-.1.3c-.3 1-.7 1.9-1.4 2.6-.1.1-.2.3-.2.4 0 .4-.1.8 0 1.2.1.8.2 1.7.3 2.5.1.5-.1.7-.5.9-.3.1-.6.2-1 .2h-1.6c0-.6 0-1.2-.5-1.5.1-.4.2-.8.3-1.3.1-.4 0-.7-.2-1s-.5-.3-.8-.2c-.8.5-1.6.5-2.5.2-.4-.1-.7-.1-.9.3q-.3.6-.3 1.2c0 .5.1 1.1.2 1.6 0 .3 0 .4-.3.5-.7.2-1.4.2-2 .1h-.1c0-.6 0-1.2-.7-1.5.4-.4.4-1.1.3-1.7m-4.1-2.3c.1-.1.2-.2.2-.4.1-.3-.2-.8-.5-.9-.2-.1-.3 0-.4.1-.3.3-.5.6-.8.9 0 .1-.1.1-.1.2-.1.2 0 .4.2.4.1 0 .3 0 .4.1.4 0 .7-.1 1-.4m0-3.3c0-.2-.2-.4-.4-.4s-.5.2-.4.5c0 .2.2.4.5.4.1-.1.3-.3.3-.5"/></svg>`;case X.vue:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="vue" class="${this.#n}" viewBox="0 0 1200 1000"><path d="m600 495.9 159.1-275.4h-84.4L600 349.7l-74.6-129.2h-84.5z"/><path d="M793.7 220.5 600 555.9 406.3 220.5H277l323 559 323-559z"/></svg>`;case X.gherkin:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="gherkin" class="${this.#n}" viewBox="0 0 32 32"><path d="M16.129 2a12.348 12.348 0 0 0-2.35 24.465V30c7.371-1.114 13.9-6.982 14.384-14.684a12.8 12.8 0 0 0-5.9-11.667 10 10 0 0 0-1.411-.707q-.117-.048-.235-.094c-.216-.08-.435-.17-.658-.236A12.2 12.2 0 0 0 16.129 2" style="fill:var(--mut-file-gherkin-color)"/><path d="M18.68 6.563a1.35 1.35 0 0 0-1.178.472 5.5 5.5 0 0 0-.518.9 2.9 2.9 0 0 0 .377 3.023A3.32 3.32 0 0 0 19.763 9 2.4 2.4 0 0 0 20 8a1.41 1.41 0 0 0-1.32-1.437m-5.488.071A1.44 1.44 0 0 0 11.85 8a2.4 2.4 0 0 0 .235 1 3.43 3.43 0 0 0 2.473 1.96 3.14 3.14 0 0 0-.212-3.85 1.32 1.32 0 0 0-1.154-.472Zm-3.7 3.637a1.3 1.3 0 0 0-.73 2.338 5.7 5.7 0 0 0 .895.543 3.39 3.39 0 0 0 3.179-.307 3.5 3.5 0 0 0-2.049-2.338 2.7 2.7 0 0 0-1.06-.236 1.4 1.4 0 0 0-.236 0Zm11.611 4.582a3.44 3.44 0 0 0-1.955.567 3.5 3.5 0 0 0 2.052 2.338 2.7 2.7 0 0 0 1.06.236 1.329 1.329 0 0 0 .966-2.362 5.5 5.5 0 0 0-.895-.52 3.3 3.3 0 0 0-1.225-.26Zm-10.292.071a3.3 3.3 0 0 0-1.225.26 2.6 2.6 0 0 0-.895.543 1.34 1.34 0 0 0 1.039 2.338 2.4 2.4 0 0 0 1.06-.236 3.19 3.19 0 0 0 1.955-2.338 3.37 3.37 0 0 0-1.931-.567Zm3.815 2.314a3.32 3.32 0 0 0-2.4 1.96 2.3 2.3 0 0 0-.236.968 1.4 1.4 0 0 0 2.426.992 5.5 5.5 0 0 0 .518-.9 3.11 3.11 0 0 0-.306-3.023Zm2.8.071a3.14 3.14 0 0 0 .212 3.85 1.47 1.47 0 0 0 2.5-.9 2.4 2.4 0 0 0-.236-.992 3.43 3.43 0 0 0-2.473-1.96Z" style="fill:#fff"/></svg>`;case X.svelte:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="svelte" class="${this.#n}" viewBox="0 0 32 32"><path d="M10.617 10.473 14.809 7.8c2.387-1.52 5.688-.812 7.359 1.58a5.12 5.12 0 0 1 .876 3.876 4.8 4.8 0 0 1-.72 1.798c.524.998.7 2.142.5 3.251a4.8 4.8 0 0 1-1.963 3.081l-.21.14-4.192 2.672c-2.386 1.52-5.688.812-7.36-1.58a5.13 5.13 0 0 1-.875-3.876c.116-.642.36-1.253.72-1.798a5.07 5.07 0 0 1-.5-3.251 4.8 4.8 0 0 1 1.962-3.081zL14.81 7.8l-4.192 2.672zm9.825.008a3.33 3.33 0 0 0-3.573-1.324q-.34.09-.65.256l-.202.118-4.192 2.671a2.9 2.9 0 0 0-1.306 1.937 3.08 3.08 0 0 0 .526 2.33 3.33 3.33 0 0 0 3.574 1.326q.34-.091.65-.256l.201-.118 1.6-1.02a1 1 0 0 1 .257-.113c.407-.105.837.054 1.077.4a.93.93 0 0 1 .158.702.87.87 0 0 1-.295.512l-.099.072-4.192 2.671a1 1 0 0 1-.257.113 1 1 0 0 1-1.076-.4.94.94 0 0 1-.171-.49l.002-.132.014-.156-.156-.047a5.4 5.4 0 0 1-1.387-.645l-.252-.174-.215-.158-.08.24a3 3 0 0 0-.1.392 3.08 3.08 0 0 0 .527 2.33 3.33 3.33 0 0 0 3.38 1.37l.194-.045q.34-.09.65-.256l.202-.118 4.192-2.671a2.9 2.9 0 0 0 1.306-1.937 3.08 3.08 0 0 0-.526-2.331 3.33 3.33 0 0 0-3.574-1.325 3 3 0 0 0-.65.257l-.201.117-1.6 1.02a1 1 0 0 1-.257.113 1 1 0 0 1-1.077-.4.93.93 0 0 1-.158-.702.87.87 0 0 1 .295-.512l.098-.072 4.192-2.671a1 1 0 0 1 .258-.113c.407-.106.836.053 1.076.399a.94.94 0 0 1 .171.49l-.002.133-.014.156.155.047c.492.148.959.365 1.388.645l.252.175.215.157.079-.24q.064-.194.1-.392a3.08 3.08 0 0 0-.526-2.33z"/></svg>`;case X.rust:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="rust" class="${this.#n}" viewBox="0 0 32 32"><path d="M21.7 8.4V9l.1.1h.1c.3-.1.6-.1.9-.2.2-.1.4.1.3.3-.1.3-.1.6-.2.9v.1l.1.1c0 .1.1.1.2.1h.9q.3 0 .3.3v.2c-.1.3-.3.6-.4.8v.1s.1.1.1.2h.1c.3.1.6.1.9.2.2 0 .3.3.2.5-.2.3-.4.5-.5.7v.2c0 .1.1.1.2.2.3.1.5.2.8.3.2.1.3.3.1.5s-.4.4-.7.6v.3s.1.1.2.1c.2.1.4.3.7.4.2.1.2.4 0 .5-.3.2-.5.3-.8.5v.1c0 .2 0 .2.1.3.2.2.4.4.6.5.2.2.1.4-.1.5-.3.1-.6.2-.8.3 0 0-.1 0-.1.1-.1.1 0 .2 0 .3.2.2.3.4.5.7.1.1.1.3-.1.4-.1 0-.1 0-.2.1-.3 0-.5.1-.8.1h-.1c0 .1-.1.1-.1.2s0 .1.1.2c.1.2.2.5.3.7.1.1 0 .3-.1.4h-1.2c-.1.1-.1.2-.1.3.1.3.1.5.2.8.1.2-.1.4-.4.4-.3-.1-.6-.1-.9-.2H22l-.1.1s-.1.1 0 .1v.9q0 .3-.3.3h-.2c-.3-.1-.5-.2-.8-.4h-.1c-.1 0-.2.1-.2.2 0 .3-.1.5-.1.8 0 .2-.3.3-.5.2-.2-.2-.5-.4-.7-.5h-.1c-.1 0-.2.1-.2.2-.1.3-.2.5-.3.8-.1.2-.2.2-.3.2S18 26 18 26c-.2-.2-.4-.4-.6-.7h-.2c-.1 0-.2.1-.2.2-.1.2-.3.5-.4.7s-.4.2-.5 0c-.2-.3-.3-.5-.5-.8h-.2c-.1 0-.1 0-.2.1l-.6.6c-.1.1-.2.1-.4.1-.1 0-.1-.1-.1-.2l-.3-.9s0-.1-.1-.1h-.3c-.2.2-.4.3-.7.5-.4-.2-.7-.3-.7-.5-.1-.3-.1-.6-.1-.9 0 0 0-.1-.1-.1s-.1-.1-.2-.1-.1 0-.2.1c-.2.1-.5.2-.7.3s-.4 0-.4-.2V23l-.1-.1h-.1c-.3.1-.6.1-.9.2-.2.1-.4-.1-.3-.3.1-.3.1-.6.2-.9v-.1l-.1-.1H8q-.3 0-.3-.3v-.2c.1-.3.3-.6.4-.8v-.1s0-.1-.1-.1c0-.1-.1-.1-.1-.1-.3 0-.6-.1-.9-.1-.2 0-.3-.3-.2-.5.2-.2.4-.5.5-.7v-.1c0-.1 0-.1-.1-.2 0 0-.1-.1-.2-.1-.2-.1-.5-.2-.7-.3s-.3-.3-.1-.5c.3-.1.5-.4.8-.6v-.2c0-.1 0-.2-.1-.2-.2-.1-.5-.3-.7-.4s-.2-.4 0-.5c.3-.2.5-.3.8-.5V15l-.1-.1-.6-.6c-.1-.1-.1-.3 0-.4 0 0 .1 0 .1-.1l.9-.3v-.1c.1-.1 0-.2 0-.3-.2-.2-.3-.4-.5-.6-.1-.2 0-.5.2-.5.3-.1.6-.1.9-.2H8c0-.1.1-.1.1-.2s0-.1-.1-.2c-.1-.2-.2-.5-.3-.7s0-.4.2-.4H9s0-.1.1-.1v-.1c-.1-.3-.2-.6-.2-.9-.1-.2.1-.4.3-.3.3 0 .6.1.9.2h.1l.1-.1s.1-.1 0-.1V8q0-.3.3-.3h.2c.3.1.6.3.8.4h.1s.1 0 .1-.1c.1 0 .1-.1.1-.1 0-.3.1-.6.1-.9 0-.2.3-.3.5-.2.2.2.5.4.7.5h.1c.1 0 .1 0 .2-.1 0 0 0-.1.1-.2.1-.2.2-.5.3-.7s.2-.2.4-.2l.1.1c.1.3.4.5.6.8h.1c.1 0 .2-.1.2-.1.1-.2.3-.5.4-.7.2-.2.4-.2.5-.1l.1.1c.2.3.3.5.5.8h.3c.1 0 .1-.1.1-.1.2-.2.4-.4.5-.6.1-.1.3-.1.4 0v.1c.1.3.2.6.3.8l.1.1h.2c.3-.1.6-.3.8-.5.2-.1.5 0 .5.2 0 .3.1.6.1.9 0 0 0 .1.1.1h.1c.1.1.2.1.2 0 .2-.1.5-.2.8-.3.2-.1.4 0 .4.3zm-11.1 2.7h7.6c.3 0 .6 0 .9.1.6.2 1.1.5 1.4.9.3.3.5.7.5 1.2q0 .6-.3 1.2c-.2.3-.5.6-.8.8-.1.1-.2.2-.3.2.1.1.2.1.3.2.2.2.5.4.6.7.2.3.3.7.3 1 0 .1.1.2.2.3.2.2.5.2.8.2.2 0 .4-.1.5-.2.2-.2.2-.4.3-.6v-.5c0-.1 0-.1.1-.1h.7v-1.3c-.3-.1-.6-.3-.9-.4-.1-.1-.3-.1-.4-.2-.3-.1-.4-.4-.3-.8.2-.5.4-1 .7-1.5v-.1c-.4-.6-.8-1.2-1.4-1.7q-1.5-1.35-3.6-1.8h-.1c-.3.3-.6.6-1 .9-.2.2-.6.2-.8 0l-.9-.9h-.1c-.4.1-.7.2-1 .3-1.1.4-2 1-2.8 1.8-.1.1-.2.2-.2.3m11.3 9.2h-3c-.2 0-.3 0-.4-.1-.4-.2-.6-.6-.7-1s-.2-.7-.2-1.1c0-.2-.1-.4-.2-.6-.2-.5-.6-.8-1.1-.8h-1.8V18h1.8c.1 0 .1 0 .1.1v2c0 .1 0 .1-.1.1h-6c.2.3.4.5.6.7h.1c.4-.1.8-.2 1.2-.2.3-.1.6.1.7.4.1.4.2.9.3 1.3v.1c.8.3 1.6.6 2.4.6.7.1 1.4 0 2.1-.1.5-.1 1-.3 1.5-.5v-.1c.1-.4.2-.9.3-1.3.1-.3.3-.5.7-.4l1.2.3h.1c0-.2.2-.5.4-.7m-11.9-7 .3.6c0 .1.1.2 0 .3 0 .2-.2.3-.3.4-.4.2-.8.4-1.2.5 0 0-.1 0-.1.1v.5c0 .7.1 1.4.3 2.2 0 0 0 .1.1.1h2.1v-4.7zm4.3 1.4q.15 0 0 0h2.3c.2 0 .4 0 .6-.1.1-.1.2-.1.3-.3s.1-.5-.1-.7-.5-.3-.7-.3h-2.5c.1.5.1.9.1 1.4m-6-1c0 .3.3.6.6.6s.6-.3.6-.6-.3-.6-.6-.6-.6.3-.6.6M21 22.1c0-.3-.3-.6-.6-.6s-.6.3-.6.6.3.6.6.6.6-.2.6-.6m-9.4-.6c-.3 0-.6.3-.6.6s.3.6.6.6.6-.3.6-.6-.3-.6-.6-.6m5-13.1c0-.3-.2-.6-.6-.6-.3 0-.6.2-.6.6 0 .3.2.6.6.6.3 0 .5-.3.6-.6m6.5 6c.3 0 .6-.3.6-.6s-.3-.6-.6-.6-.6.3-.6.6.2.6.6.6"/></svg>`;case X.python:return M`<svg xmlns="http://www.w3.org/2000/svg" aria-label="python" class="${this.#n}" viewBox="0 0 32 32"><path d="M15.6 15.5h-2c-1.4 0-2.3.9-2.3 2.3v1.8q0 .3-.3.3h-.9c-.9 0-1.6-.4-2-1.2-.3-.6-.5-1.2-.5-1.8-.1-1.1-.1-2.2.3-3.3.3-.9.9-1.6 1.9-1.8h5.8c.1 0 .3 0 .3-.1v-.5s-.2-.1-.3-.1h-3.4c-.3 0-.4-.1-.4-.4V9.4c0-.7.3-1.2.9-1.4.5-.2 1-.4 1.5-.5 1.2-.2 2.4-.2 3.6.1.5.1 1 .3 1.4.6.4.4.7.8.6 1.4v3.6c0 1.4-.8 2.2-2.2 2.2-.7.1-1.4.1-2 .1m-2.8-6c0 .4.3.8.8.8.4 0 .8-.4.8-.8s-.4-.7-.8-.8c-.5 0-.8.4-.8.8m3.6 7h2c1.4 0 2.3-.9 2.3-2.3v-1.8q0-.3.3-.3h.9c.9 0 1.6.4 2 1.2.3.6.5 1.2.5 1.8.1 1.1.1 2.2-.3 3.3-.3.9-.9 1.6-1.9 1.8h-5.8c-.1 0-.3 0-.3.1v.5s.2.1.3.1h3.4c.3 0 .4.1.4.4v1.3c0 .7-.3 1.2-.9 1.4-.5.2-1 .4-1.5.5-1.2.2-2.4.2-3.6-.1-.5-.1-1-.3-1.4-.6-.4-.4-.7-.8-.6-1.4v-3.6c0-1.4.8-2.2 2.2-2.2.7-.1 1.4-.1 2-.1m2.8 6c0-.4-.3-.8-.8-.8-.4 0-.8.4-.8.8s.4.7.8.8c.5 0 .8-.4.8-.8"/></svg>`}}};G([I({attribute:`file-name`})],eo.prototype,`fileName`,void 0),G([I({type:Boolean})],eo.prototype,`file`,void 0),eo=G([F(`mte-file-icon`)],eo);var to=class extends ei{render(){return j`<span class="cursor-help underline decoration-dotted" title=${this.title}><slot></slot></span>`}};G([I({attribute:!0})],to.prototype,`title`,void 0),to=G([F(`mte-tooltip`)],to);var no=class{constructor(e,{target:t,config:n,callback:r,skipInitial:i}){this.t=new Set,this.o=!1,this.i=!1,this.h=e,t!==null&&this.t.add(t??e),this.o=i??this.o,this.callback=r,window.IntersectionObserver?(this.u=new IntersectionObserver(e=>{let t=this.i;this.i=!1,this.o&&t||(this.handleChanges(e),this.h.requestUpdate())},n),e.addController(this)):console.warn(`IntersectionController error: browser does not support IntersectionObserver.`)}handleChanges(e){this.value=this.callback?.(e,this.u)}hostConnected(){for(let e of this.t)this.observe(e)}hostDisconnected(){this.disconnect()}async hostUpdated(){let e=this.u.takeRecords();e.length&&this.handleChanges(e)}observe(e){this.t.add(e),this.u.observe(e),this.i=!0}unobserve(e){this.t.delete(e),this.u.unobserve(e)}disconnect(){this.u.disconnect()}},ro=class extends ei{#e;constructor(){super(),this.detected=0,this.noCoverage=0,this.pending=0,this.survived=0,this.total=0,this.#e=new no(this,{callback:([e])=>!e.isIntersecting})}render(){return j`${this.#t()}
309
+ <div data-test-id="progress-bar" class="my-4 rounded-md bg-white transition-all">
310
+ <div class="parts flex h-8 w-full overflow-hidden rounded-sm bg-gray-200">${this.#n(!1)}</div>
311
+ </div>`}#t(){return j`<div
312
+ data-test-id="small-progress-bar"
313
+ class="${this.#e.value?`opacity-100`:`opacity-0`} pointer-events-none fixed top-offset left-0 z-20 flex w-full justify-center transition-all"
314
+ >
315
+ <div class="container w-full bg-white py-2">
316
+ <div class="flex h-2 overflow-hidden rounded-sm bg-gray-200">${this.#n(!0)}</div>
317
+ </div>
318
+ </div>`}#n(e){return Y(this.#r(),e=>e.type,t=>j`<div
319
+ title=${e?P:t.tooltip}
320
+ style="width: ${this.#a(t.amount)}%"
321
+ class="${this.#i(t.type)} ${t.amount===0?`opacity-0`:`opacity-100`} relative flex items-center overflow-hidden motion-safe:transition-width"
322
+ >${e?P:j`<span class="ms-3 font-bold text-gray-800">${t.amount}</span>`}
323
+ </div>`)}#r(){return[{type:`detected`,amount:this.detected,tooltip:`killed + timeout (${this.detected})`},{type:`survived`,amount:this.survived,tooltip:`survived (${this.survived})`},{type:`no coverage`,amount:this.noCoverage,tooltip:`no coverage (${this.noCoverage})`},{type:`pending`,amount:this.pending,tooltip:`pending`}]}#i(e){switch(e){case`detected`:return`bg-green-600`;case`survived`:return`bg-red-600`;case`no coverage`:return`bg-yellow-600`;default:return`bg-gray-200`}}#a(e){return this.total===0?0:100*e/this.total}};return G([I({type:Number})],ro.prototype,`detected`,void 0),G([I({type:Number,attribute:`no-coverage`})],ro.prototype,`noCoverage`,void 0),G([I({type:Number})],ro.prototype,`pending`,void 0),G([I({type:Number})],ro.prototype,`survived`,void 0),G([I({type:Number})],ro.prototype,`total`,void 0),ro=G([F(`mte-result-status-bar`)],ro),Object.defineProperty(e,`MutationTestReportAppComponent`,{enumerable:!0,get:function(){return J}}),e})({});
324
+ </script>
325
+ </head>
326
+ <body>
327
+ <svg style="width: 80px; position:fixed; right:10px; bottom:10px; z-index:10" class="stryker-image" viewBox="0 0 1458 1458" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><path fill="none" d="M0 0h1458v1458H0z"/><clipPath id="a"><path d="M0 0h1458v1458H0z"/></clipPath><g clip-path="url(#a)"><path d="M1458 729c0 402.655-326.345 729-729 729S0 1131.655 0 729C0 326.445 326.345 0 729 0s729 326.345 729 729" fill="#e74c3c" fill-rule="nonzero"/><path d="M778.349 1456.15L576.6 1254.401l233-105 85-78.668v-64.332l-257-257-44-187-50-208 251.806-82.793L1076.6 389.401l380.14 379.15c-19.681 367.728-311.914 663.049-678.391 687.599z" fill-opacity=".3"/><path d="M753.4 329.503c41.79 0 74.579 7.83 97.925 25.444 23.571 18.015 41.69 43.956 55.167 77.097l11.662 28.679 165.733-58.183-14.137-32.13c-26.688-60.655-64.896-108.61-114.191-144.011-49.329-35.423-117.458-54.302-204.859-54.302-50.78 0-95.646 7.376-134.767 21.542-40.093 14.671-74.09 34.79-102.239 60.259-28.84 26.207-50.646 57.06-65.496 92.701-14.718 35.052-22.101 72.538-22.101 112.401 0 72.536 20.667 133.294 61.165 182.704 38.624 47.255 98.346 88.037 179.861 121.291 42.257 17.475 78.715 33.125 109.227 46.994 27.193 12.361 49.294 26.124 66.157 41.751 15.309 14.186 26.497 30.584 33.63 49.258 7.721 20.214 11.16 45.69 11.16 76.402 0 28.021-4.251 51.787-13.591 71.219-8.832 18.374-20.171 33.178-34.523 44.219-14.787 11.374-31.193 19.591-49.393 24.466-19.68 5.359-39.14 7.993-58.69 7.993-29.359 0-54.387-3.407-75.182-10.747-20.112-7.013-37.144-16.144-51.259-27.486-13.618-11.009-24.971-23.766-33.744-38.279-9.64-15.8-17.272-31.924-23.032-48.408l-10.965-31.376-161.669 60.585 10.734 30.124c10.191 28.601 24.197 56.228 42.059 82.748 18.208 27.144 41.322 51.369 69.525 72.745 27.695 21.075 60.904 38.218 99.481 51.041 37.777 12.664 82.004 19.159 132.552 19.159 49.998 0 95.818-8.321 137.611-24.622 42.228-16.471 78.436-38.992 108.835-67.291 30.719-28.597 54.631-62.103 71.834-100.642 17.263-38.56 25.923-79.392 25.923-122.248 0-54.339-8.368-100.37-24.208-138.32-16.29-38.759-38.252-71.661-65.948-98.797-26.965-26.418-58.269-48.835-93.858-67.175-33.655-17.241-69.196-33.11-106.593-47.533-35.934-13.429-65.822-26.601-89.948-39.525-22.153-11.868-40.009-24.21-53.547-37.309-11.429-11.13-19.83-23.678-24.718-37.664-5.413-15.49-7.98-33.423-7.98-53.577 0-40.883 11.293-71.522 37.086-90.539 28.443-20.825 64.985-30.658 109.311-30.658z" fill="#f1c40f" fill-rule="nonzero"/><path d="M720 0h18v113h-18zM1458 738v-18h-113v18h113zM720 1345h18v113h-18zM113 738v-18H0v18h113z"/></g></svg>
328
+ <mutation-test-report-app titlePostfix="Stryker">
329
+ Your browser doesn't support <a href="https://caniuse.com/#search=custom%20elements">custom elements</a>.
330
+ Please use a latest version of an evergreen browser (Firefox, Chrome, Safari, Opera, Edge, etc).
331
+ </mutation-test-report-app>
332
+ <script>
333
+ const app = document.querySelector('mutation-test-report-app');
334
+ app.report = {"files":{"src/protocol/base64.ts":{"language":"typescript","mutants":[{"id":"1","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [ Array(1) ] to deeply equal [ …(2) ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["914"],"coveredBy":["914","915","916","917","918","919","920","921","922","923","924","925","926","927","928","929","930","942","948","950","952","956"],"location":{"end":{"column":2,"line":16},"start":{"column":53,"line":14}}},{"id":"0","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'split')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["914"],"coveredBy":["914","915","916","917","918","919","920","921","922","923","924","925","926","927","928","929","930","942","947","948","950","951","956"],"location":{"end":{"column":2,"line":11},"start":{"column":53,"line":9}}}],"source":"/**\n * Typed base64 helpers backed by the global `atob` / `btoa` available on\n * every supported runtime (Node 16+, Cloudflare Workers, browsers). The\n * project pins `lib: ES2022` which omits these names from the type table,\n * so this module re-exports them with proper signatures.\n */\n\n/** Decodes a base64 string to a Latin1/binary string (char per byte). */\nexport function decodeBase64(input: string): string {\n return (globalThis as unknown as { atob: (s: string) => string }).atob(input);\n}\n\n/** Encodes a Latin1/binary string (char per byte, 0–255) to base64. */\nexport function encodeBase64(input: string): string {\n return (globalThis as unknown as { btoa: (s: string) => string }).btoa(input);\n}\n"},"src/protocol/batch.ts":{"language":"typescript","mutants":[{"id":"2","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading '0')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["490"],"coveredBy":["103","106","108","109","110","111","112","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":2,"line":85},"start":{"column":15,"line":56}}},{"id":"3","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected [ Array(1) ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","111","112","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":25,"line":57},"start":{"column":7,"line":57}}},{"id":"5","mutatorName":"EqualityOperator","replacement":"lines.length !== 0","statusReason":"expected [ Array(1) ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","111","112","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":25,"line":57},"start":{"column":7,"line":57}}},{"id":"4","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [ { text: 'BATCH +abc names' }, …(1) ] to deeply equal []","status":"Killed","static":false,"testsCompleted":28,"killedBy":["111"],"coveredBy":["103","106","108","109","110","111","112","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":25,"line":57},"start":{"column":7,"line":57}}},{"id":"6","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [ { text: 'BATCH +abc names' }, …(1) ] to deeply equal []","status":"Killed","static":false,"testsCompleted":1,"killedBy":["111"],"coveredBy":["111","112"],"location":{"end":{"column":4,"line":69},"start":{"column":27,"line":57}}},{"id":"7","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'ref7' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":6,"line":67},"start":{"column":36,"line":61}}},{"id":"8","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'ref7' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":24,"line":62},"start":{"column":11,"line":62}}},{"id":"9","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'chathistory' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":28,"line":63},"start":{"column":13,"line":63}}},{"id":"10","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":37,"line":64},"start":{"column":14,"line":64}}},{"id":"11","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected {} to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":35,"line":64},"start":{"column":23,"line":64}}},{"id":"12","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { text: 'Stryker was here!' } to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":33,"line":64},"start":{"column":31,"line":64}}},{"id":"13","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to deeply equal []","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":26,"line":65},"start":{"column":13,"line":65}}},{"id":"14","mutatorName":"ArrayDeclaration","replacement":"[\"Stryker was here\"]","statusReason":"expected [ 'Stryker was here' ] to deeply equal []","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":24,"line":65},"start":{"column":22,"line":65}}},{"id":"15","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":35,"line":66},"start":{"column":12,"line":66}}},{"id":"16","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected {} to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":33,"line":66},"start":{"column":21,"line":66}}},{"id":"17","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { text: 'Stryker was here!' } to deeply equal { text: '' }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["112"],"coveredBy":["111","112"],"location":{"end":{"column":31,"line":66},"start":{"column":29,"line":66}}},{"id":"18","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected [ { tag: 'Send', to: 'c1', …(1) } ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":39,"line":71},"start":{"column":21,"line":71}}},{"id":"19","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected 'BATCH +id1 netjoin' to be 'BATCH +id1 netjoin #foo' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["110"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":39,"line":71},"start":{"column":21,"line":71}}},{"id":"20","mutatorName":"EqualityOperator","replacement":"args === undefined","statusReason":"expected [ { tag: 'Send', to: 'c1', …(1) } ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":39,"line":71},"start":{"column":21,"line":71}}},{"id":"21","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to be 'BATCH +id1 netjoin #foo' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["110"],"coveredBy":["110","318","319","320","321","323","324","325","326","328","329","350","351","352","353","354","355","490","491","495","496","497"],"location":{"end":{"column":72,"line":71},"start":{"column":42,"line":71}}},{"id":"22","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected [ { tag: 'Send', to: 'c1', …(1) } ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","113","114","115","332","490","491","492","493","494","495","496","497"],"location":{"end":{"column":97,"line":71},"start":{"column":75,"line":71}}},{"id":"23","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected [ {}, …(3) ] to deeply equal [ { text: 'BATCH +abc names' }, …(3) ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["109"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":45,"line":72},"start":{"column":26,"line":72}}},{"id":"24","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected [ { tag: 'Send', to: 'c1', …(1) } ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":48,"line":73},"start":{"column":24,"line":73}}},{"id":"25","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected [ { text: 'BATCH +abc names' }, …(3) ] to deeply equal [ { text: 'BATCH +abc names' }, …(3) ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["109"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":46,"line":73},"start":{"column":32,"line":73}}},{"id":"26","mutatorName":"ArrayDeclaration","replacement":"[]","statusReason":"expected [ { tag: 'Send', to: 'c1', …(1) } ] to deeply equal [ { tag: 'Send', to: 'c1', …(1) } ]","status":"Killed","static":false,"testsCompleted":1,"killedBy":["103"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":26,"line":74},"start":{"column":16,"line":74}}},{"id":"27","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'id' // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":4,"line":83},"start":{"column":32,"line":77}}},{"id":"29","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'topic' // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":26,"line":79},"start":{"column":11,"line":79}}},{"id":"28","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be 'id' // Object.is equality","status":"Killed","static":false,"testsCompleted":7,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":22,"line":78},"start":{"column":9,"line":78}}},{"id":"30","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'text')","status":"Killed","static":false,"testsCompleted":4,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":28,"line":80},"start":{"column":12,"line":80}}},{"id":"31","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Target cannot be null or undefined.","status":"Killed","static":false,"testsCompleted":4,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":26,"line":81},"start":{"column":11,"line":81}}},{"id":"32","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'text')","status":"Killed","static":false,"testsCompleted":4,"killedBy":["114"],"coveredBy":["103","106","108","109","110","113","114","115","318","319","320","321","323","324","325","326","328","329","332","350","351","352","353","354","355","490","491","492","493","494","495","496","497"],"location":{"end":{"column":24,"line":82},"start":{"column":10,"line":82}}}],"source":"/**\n * IRCv3 `batch` framing helper.\n *\n * Spec: https://ircv3.net/specs/extensions/batch-3.3\n *\n * `wrapBatch` is a pure transformation: given an ordered list of inner\n * {@link RawLine}s and a batch reference id, it returns a new line list\n * bounded by `BATCH +id type [args]` (start) and `BATCH -id` (end). The\n * caller is responsible for allocating the id (via {@link IdFactory}) so\n * the function remains deterministic and free of side effects.\n *\n * Nested batches are simply an outer `wrapBatch` call whose `lines`\n * argument is the result of an inner `wrapBatch` call: the framing is\n * composable by design. The caller MUST allocate distinct ids per batch\n * (the spec forbids reuse of a ref id while the batch is open).\n *\n * Without the `batch` cap negotiated, the dispatch layer omits the\n * wrapping entirely and the recipient observes the inner lines verbatim.\n */\n\nimport type { RawLine } from '../effects.js';\n\n/** Output of {@link wrapBatch}: the framing plus the inner body as one list. */\nexport interface BatchFrame extends Array<"+"RawLine> {\n /** The reference id used in the `+id`/`-id` markers. */\n readonly id: string;\n /** The batch type token (e.g. `names`, `join`, `netjoin`). */\n readonly type: string;\n /** The `BATCH +id type [args]` start marker. */\n readonly start: RawLine;\n /** The original inner lines, unmodified. */\n readonly body: RawLine[];\n /** The `BATCH -id` end marker. */\n readonly end: RawLine;\n}\n\n/**\n * Wraps `lines` in IRCv3 `BATCH` framing.\n *\n * @param lines Inner batch contents (in send order). Empty input returns an\n * empty array unchanged — callers should not emit empty batches.\n * @param id Batch reference id (allocate via `IdFactory.batchId()`).\n * @param type Batch type token (opaque to clients; known types like\n * `netjoin`, `netsplit`, `chghost`, `topic` are honoured by\n * clients that understand them).\n * @param args Optional trailing parameters appended after the type token\n * (e.g. the channel name for a `netjoin #foo` batch). Omitted\n * entirely when `undefined`.\n * @returns A new {@link BatchFrame} (array) suitable to be sent in order.\n */\nexport function wrapBatch(\n lines: readonly RawLine[],\n id: string,\n type: string,\n args?: string,\n): BatchFrame {\n if (lines.length === 0) {\n // Return an empty BatchFrame so the caller's array shape is stable.\n // Empty batches are forbidden by the spec; we silently elide them.\n const empty = [] as unknown as BatchFrame;\n Object.defineProperties(empty, {\n id: { value: id },\n type: { value: type },\n start: { value: { text: '' } },\n body: { value: [] },\n end: { value: { text: '' } },\n });\n return empty;\n }\n\n const startText = args !== undefined ? `BATCH +${id} ${type} ${args}` : `BATCH +${id} ${type}`;\n const start: RawLine = { text: startText };\n const end: RawLine = { text: `BATCH -${id}` };\n const body = [...lines];\n\n const out = [start, ...body, end] as unknown as BatchFrame;\n Object.defineProperties(out, {\n id: { value: id },\n type: { value: type },\n start: { value: start },\n body: { value: body },\n end: { value: end },\n });\n return out;\n}\n"},"src/protocol/numerics.ts":{"language":"typescript","mutants":[{"id":"39","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be true // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["38"],"coveredBy":["38","39","40","41","42","43","44","45","46","47","48","49","50"],"location":{"end":{"column":2,"line":210},"start":{"column":60,"line":208}}},{"id":"40","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be '001' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["51"],"coveredBy":["51","52","53","54","55"],"location":{"end":{"column":2,"line":218},"start":{"column":58,"line":216}}},{"id":"41","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '1' to be '001' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["51"],"coveredBy":["51","52","53","54","55"],"location":{"end":{"column":51,"line":217},"start":{"column":48,"line":217}}},{"id":"34","mutatorName":"ArrayDeclaration","replacement":"[]","statusReason":"expected undefined to be 'ERR_NICKNAMEINUSE' // Object.is equality","status":"Killed","static":true,"testsCompleted":76,"killedBy":["29"],"coveredBy":[],"location":{"end":{"column":4,"line":202},"start":{"column":92,"line":199}}},{"id":"35","mutatorName":"Regex","replacement":"/[0-9]{3}$/","statusReason":"expected true to be false // Object.is equality","status":"Killed","static":true,"testsCompleted":49,"killedBy":["48"],"coveredBy":[],"location":{"end":{"column":40,"line":205},"start":{"column":28,"line":205}}},{"id":"36","mutatorName":"Regex","replacement":"/^[0-9]{3}/","statusReason":"expected true to be false // Object.is equality","status":"Killed","static":true,"testsCompleted":49,"killedBy":["48"],"coveredBy":[],"location":{"end":{"column":40,"line":205},"start":{"column":28,"line":205}}},{"id":"37","mutatorName":"Regex","replacement":"/^[0-9]$/","statusReason":"expected false to be true // Object.is equality","status":"Killed","static":true,"testsCompleted":39,"killedBy":["38"],"coveredBy":[],"location":{"end":{"column":40,"line":205},"start":{"column":28,"line":205}}},{"id":"38","mutatorName":"Regex","replacement":"/^[^0-9]{3}$/","statusReason":"expected false to be true // Object.is equality","status":"Killed","static":true,"testsCompleted":39,"killedBy":["38"],"coveredBy":[],"location":{"end":{"column":40,"line":205},"start":{"column":28,"line":205}}},{"id":"33","mutatorName":"ArrowFunction","replacement":"() => undefined","status":"Survived","static":true,"testsCompleted":0,"coveredBy":[],"location":{"end":{"column":4,"line":202},"start":{"column":74,"line":199}}}],"source":"/**\n * IRC numeric reply/error codes (RFC 1459/2812 + common modern extensions).\n *\n * One canonical name per code (no aliases) so the reverse map is unambiguous.\n * @see PLAN.md §3 for the supported subset.\n */\nexport const Numerics = {\n // Welcome / registration\n RPL_WELCOME: 1,\n RPL_YOURHOST: 2,\n RPL_CREATED: 3,\n RPL_MYINFO: 4,\n RPL_ISUPPORT: 5,\n RPL_BOUNCE: 10,\n\n // Capability / stats admin / version\n RPL_ADMINME: 256,\n\n // LUSERS (RFC 2812 §4.6.2). Network-wide user/channel/server counts.\n // 251 is the human-readable summary; 252–254 are emitted only when their\n // count is non-zero (per RFC). 255 is the local-server summary. 265/266\n // carry the optional local/global max-connection context.\n RPL_LUSERCLIENT: 251,\n RPL_LUSEROP: 252,\n RPL_LUSERUNKNOWN: 253,\n RPL_LUSERCHANNELS: 254,\n RPL_LUSERME: 255,\n RPL_LOCALUSERS: 265,\n RPL_GLOBALUSERS: 266,\n\n // STATS (RFC 2812 §4.6.3 / §5.1). 211 is link-info (the `l` query),\n // 242 is the uptime line (the `u` query), and 219 terminates every\n // STATS reply regardless of query letter. The remaining RPL_STATS*\n // codes (212–218, 240–248) are deferred behind per-letter tests.\n RPL_STATSLINKINFO: 211,\n RPL_ENDOFSTATS: 219,\n RPL_STATSUPTIME: 242,\n\n // TRACE (RFC 2812 §4.6.3). In a single-server deployment TRACE collapses\n // to a local-server line (200/206) plus optional per-connection detail\n // (204 oper / 205 user), terminated by 262. Multi-hop S2S tracing is a\n // PLAN non-goal.\n RPL_TRACELINK: 200,\n RPL_TRACEOPERATOR: 204,\n RPL_TRACEUSER: 205,\n RPL_TRACESERVER: 206,\n RPL_ENDOFTRACE: 262,\n\n // Away / userhost / ison\n RPL_AWAY: 301,\n RPL_USERHOST: 302,\n RPL_ISON: 303,\n RPL_UNAWAY: 305,\n RPL_NOWAWAY: 306,\n\n // IRCv3 MONITOR (modern ISON replacement). The watchlist lives on\n // {@link ConnectionState.monitorList}; transitions push 730/731\n // asynchronously via the actor's nick-registry hooks.\n RPL_MONONLINE: 730,\n RPL_MONOFFLINE: 731,\n RPL_MONLIST: 732,\n RPL_ENDOFMONLIST: 733,\n ERR_MONLISTFULL: 734,\n\n // Version\n RPL_VERSION: 351,\n\n // WHOIS / WHOWAS\n RPL_WHOISUSER: 311,\n RPL_WHOISSERVER: 312,\n RPL_WHOISOPERATOR: 313,\n RPL_WHOISIDLE: 317,\n RPL_ENDOFWHOIS: 318,\n RPL_WHOISCHANNELS: 319,\n RPL_WHOISACCOUNT: 330,\n /**\n * `276 RPL_WHOISSECURE` — target is connected over TLS (user mode `S`).\n * Surfaced between the oper/account lines and the idle line so clients\n * render it as part of the secure-connection WHOIS block.\n */\n RPL_WHOISSECURE: 276,\n RPL_WHOWASUSER: 314,\n\n // LIST\n RPL_LISTSTART: 321,\n RPL_LIST: 322,\n RPL_LISTEND: 323,\n\n // Channel mode / metadata\n RPL_CHANNELMODEIS: 324,\n RPL_CREATIONTIME: 329,\n RPL_NOTOPIC: 331,\n RPL_TOPIC: 332,\n RPL_TOPICWHOTIME: 333,\n\n // Invite / except lists\n RPL_INVITING: 341,\n RPL_INVITELIST: 346,\n RPL_ENDOFINVITELIST: 347,\n RPL_EXCEPTLIST: 348,\n RPL_ENDOFEXCEPTLIST: 349,\n\n // WHO / NAMES\n RPL_WHOREPLY: 352,\n RPL_ENDOFWHO: 315,\n RPL_NAMREPLY: 353,\n RPL_ENDOFNAMES: 366,\n\n // Ban / whowas. (The S2S `LINKS` verb and its `RPL_LINKS`/`RPL_ENDOFLINKS`\n // numerics were dropped — S2S is a PLAN non-goal.)\n RPL_BANLIST: 367,\n RPL_ENDOFBANLIST: 368,\n RPL_ENDOFWHOWAS: 369,\n\n // Info / MOTD\n RPL_INFO: 371,\n RPL_ENDOFINFO: 374,\n RPL_MOTD: 372,\n RPL_MOTDSTART: 375,\n RPL_ENDOFMOTD: 376,\n\n // Oper. RPL_REHASHING is emitted by the oper-gated `REHASH` reducer when a\n // config reload is requested (the actor re-invokes the adapter's loader).\n // (The RFC 2812 `SERVICE` verb and its `RPL_YOURESERVICE` numeric were\n // dropped — obsolete, never widely implemented.)\n RPL_YOUREOPER: 381,\n RPL_REHASHING: 382,\n RPL_TIME: 391,\n\n // SASL (IRCv3 sasl-3.2)\n RPL_LOGGEDIN: 900,\n RPL_LOGGEDOUT: 901,\n ERR_NICKLOCKED: 902,\n RPL_SASLSUCCESS: 903,\n ERR_SASLFAIL: 904,\n ERR_SASLTOOLONG: 905,\n ERR_SASLABORT: 906,\n ERR_SASLALREADY: 907,\n ERR_SASLMECHS: 908,\n\n // Errors\n ERR_NOSUCHNICK: 401,\n ERR_NOSUCHSERVER: 402,\n ERR_NOSUCHCHANNEL: 403,\n ERR_CANNOTSENDTOCHAN: 404,\n ERR_TOOMANYCHANNELS: 405,\n ERR_WASNOSUCHNICK: 406,\n ERR_TOOMANYTARGETS: 407,\n // `ERR_NOSUCHSERVICE` (408) was dropped with the obsolete RFC 2812 `SERVICE`\n // verb.\n ERR_INVALIDCAPCMD: 410,\n ERR_NORECIPIENT: 411,\n ERR_NOTEXTTOSEND: 412,\n ERR_NOTOPLEVEL: 413,\n ERR_WILDTOPLEVEL: 414,\n ERR_UNKNOWNCOMMAND: 421,\n ERR_NOMOTD: 422,\n ERR_NOADMININFO: 423,\n ERR_FILEERROR: 424,\n ERR_NONICKNAMEGIVEN: 431,\n ERR_ERRONEUSNICKNAME: 432,\n ERR_NICKNAMEINUSE: 433,\n ERR_NICKCOLLISION: 436,\n ERR_UNAVAILRESOURCE: 437,\n ERR_USERNOTINCHANNEL: 441,\n ERR_NOTONCHANNEL: 442,\n ERR_USERONCHANNEL: 443,\n ERR_NOLOGIN: 444,\n // `ERR_SUMMONDISABLED` (445) / `ERR_USERSDISABLED` (446) were dropped with\n // the obsolete RFC 2812 `SUMMON`/`USERS` verbs.\n ERR_NOTREGISTERED: 451,\n ERR_NEEDMOREPARAMS: 461,\n ERR_ALREADYREGISTRED: 462,\n ERR_NOPERMFORHOST: 463,\n ERR_PASSWDMISMATCH: 464,\n ERR_YOUREBANNEDCREEP: 465,\n ERR_KEYSET: 467,\n ERR_CHANNELISFULL: 471,\n ERR_UNKNOWNMODE: 472,\n ERR_INVITEONLYCHAN: 473,\n ERR_BANNEDFROMCHAN: 474,\n ERR_BADCHANNELKEY: 475,\n ERR_BADCHANMASK: 476,\n ERR_NOCHANMODES: 477,\n ERR_BANLISTFULL: 478,\n ERR_NOPRIVILEGES: 481,\n ERR_CHANOPRIVSNEEDED: 482,\n // `ERR_CANTKILLSERVER` (483) was dropped with the S2S `SQUIT` verb — S2S is\n // a PLAN non-goal.\n ERR_NOOPERHOST: 491,\n ERR_UMODEUNKNOWNFLAG: 501,\n ERR_USERSDONTMATCH: 502,\n} as const;\n\nexport type NumericCode = keyof typeof Numerics;\n\n/** Reverse lookup: numeric code -> canonical name. */\nexport const numericToName: ReadonlyMap<"+"number, NumericCode> = new Map(\n (Object.entries(Numerics) as ReadonlyArray<"+"[NumericCode, number]>).map(([name, code]) => [\n code,\n name,\n ]),\n);\n\nconst NUMERIC_COMMAND_RE = /^[0-9]{3}$/;\n\n/** A command token is a numeric iff it is exactly three digits. */\nexport function isNumericCommand(command: string): boolean {\n return NUMERIC_COMMAND_RE.test(command);\n}\n\n/**\n * Formats a numeric by name as its three-digit wire representation.\n * Zero-pads codes below 100.\n */\nexport function formatNumeric(name: NumericCode): string {\n return Numerics[name].toString().padStart(3, '0');\n}\n"},"src/protocol/outbound.ts":{"language":"typescript","mutants":[{"id":"42","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be '2024-01-15T14:34:56.789Z' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["178"],"coveredBy":["161","162","178","179","180","181","182","185","186","187","204","318","319","320","321","323","324","325","326","328","329","332","333","350","351","352","353","354","355","360","361","362","490","491","495","496","497"],"location":{"end":{"column":2,"line":42},"start":{"column":55,"line":40}}},{"id":"43","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'text')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["161"],"coveredBy":["161","162","163","182","183","184","185","186","187","204"],"location":{"end":{"column":2,"line":66},"start":{"column":97,"line":57}}},{"id":"44","mutatorName":"BooleanLiteral","replacement":"caps.has('server-time')","statusReason":"expected ':alice!alice@example.com PRIVMSG #foo…' to be '@time=2024-01-15T14:34:56.789Z :alice…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["182"],"coveredBy":["161","162","163","182","183","184","185","186","187","204"],"location":{"end":{"column":31,"line":58},"start":{"column":7,"line":58}}},{"id":"45","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be '@time=2024-01-15T14:34:56.789Z;+typin…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["161"],"coveredBy":["161","162","163","182","183","184","185","186","187","204"],"location":{"end":{"column":31,"line":58},"start":{"column":7,"line":58}}},{"id":"46","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { Object (text) } to be { Object (text) } // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["183"],"coveredBy":["161","162","163","182","183","184","185","186","187","204"],"location":{"end":{"column":31,"line":58},"start":{"column":7,"line":58}}},{"id":"47","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be '@time=2024-01-15T14:34:56.789Z;+typin…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["161"],"coveredBy":["161","162","163","182","183","184","185","186","187","204"],"location":{"end":{"column":30,"line":58},"start":{"column":17,"line":58}}},{"id":"48","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@time=2024-01-15T14:34:56.789Z;alice!…' to be '@time=2024-01-15T14:34:56.789Z :alice…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["182"],"coveredBy":["161","162","182","185","186","187","204"],"location":{"end":{"column":32,"line":60},"start":{"column":7,"line":60}}},{"id":"49","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected '@time=2024-01-15T14:34:56.789Z @+typi…' to be '@time=2024-01-15T14:34:56.789Z;+typin…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["161"],"coveredBy":["161","162","182","185","186","187","204"],"location":{"end":{"column":32,"line":60},"start":{"column":7,"line":60}}},{"id":"50","mutatorName":"MethodExpression","replacement":"line.text.endsWith('@')","statusReason":"expected '@time=2024-01-15T14:34:56.789Z @accou…' to be '@time=2024-01-15T14:34:56.789Z;accoun…' // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["187"],"coveredBy":["161","162","182","185","186","187","204"],"location":{"end":{"column":32,"line":60},"start":{"column":7,"line":60}}},{"id":"52","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected '@time=2024-01-15T14:34:56.789Z @accou…' to be '@time=2024-01-15T14:34:56.789Z;accoun…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["187"],"coveredBy":["161","162","187"],"location":{"end":{"column":4,"line":64},"start":{"column":34,"line":60}}},{"id":"51","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@time=2024-01-15T14:34:56.789Z;alice!…' to be '@time=2024-01-15T14:34:56.789Z :alice…' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["182"],"coveredBy":["161","162","182","185","186","187","204"],"location":{"end":{"column":31,"line":60},"start":{"column":28,"line":60}}},{"id":"53","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to be '@time=2024-01-15T14:34:56.789Z;accoun…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["187"],"coveredBy":["161","162","187"],"location":{"end":{"column":58,"line":62},"start":{"column":20,"line":62}}},{"id":"54","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected '@time=2024-01-15T14:34:56.789Z;@accou…' to be '@time=2024-01-15T14:34:56.789Z;accoun…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["187"],"coveredBy":["161","162","187"],"location":{"end":{"column":56,"line":62},"start":{"column":38,"line":62}}},{"id":"55","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be '@time=2024-01-15T14:34:56.789Z;accoun…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["187"],"coveredBy":["161","162","187"],"location":{"end":{"column":28,"line":63},"start":{"column":12,"line":63}}},{"id":"56","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be '@time=2024-01-15T14:34:56.789Z :alice…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["182"],"coveredBy":["182","185","186","204"],"location":{"end":{"column":49,"line":65},"start":{"column":10,"line":65}}},{"id":"57","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to be '@time=2024-01-15T14:34:56.789Z :alice…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["182"],"coveredBy":["182","185","186","204"],"location":{"end":{"column":47,"line":65},"start":{"column":18,"line":65}}},{"id":"58","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be { text: ':alice PRIVMSG #foo :hi' } // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["188"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":2,"line":116},"start":{"column":12,"line":91}}},{"id":"59","mutatorName":"ArithmeticOperator","replacement":"MAX_LINE_BYTES + TRAILING_CRLF_LEN","statusReason":"expected 514 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["190"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","200","201"],"location":{"end":{"column":94,"line":92},"start":{"column":60,"line":92}}},{"id":"60","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected 515 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["190"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":33,"line":93},"start":{"column":7,"line":93}}},{"id":"61","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { text: ':alice PRIVMSG #foo :hi' } to be { text: ':alice PRIVMSG #foo :hi' } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { text: ':alice PRIVMSG #foo :hi' }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":1,"killedBy":["188"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":33,"line":93},"start":{"column":7,"line":93}}},{"id":"62","mutatorName":"EqualityOperator","replacement":"line.text.length <"+" budget","statusReason":"expected { Object (text) } to be { Object (text) } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { Object (text) }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":11,"killedBy":["198"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":33,"line":93},"start":{"column":7,"line":93}}},{"id":"63","mutatorName":"EqualityOperator","replacement":"line.text.length > budget","statusReason":"expected { text: ':alice PRIVMSG #foo :hi' } to be { text: ':alice PRIVMSG #foo :hi' } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { text: ':alice PRIVMSG #foo :hi' }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":1,"killedBy":["188"],"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":33,"line":93},"start":{"column":7,"line":93}}},{"id":"64","mutatorName":"ConditionalExpression","replacement":"true","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":72,"line":95},"start":{"column":19,"line":95}}},{"id":"65","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":72,"line":95},"start":{"column":19,"line":95}}},{"id":"66","mutatorName":"LogicalOperator","replacement":"caps.has('message-tags') || line.text.startsWith('@')","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":72,"line":95},"start":{"column":19,"line":95}}},{"id":"67","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":42,"line":95},"start":{"column":28,"line":95}}},{"id":"68","mutatorName":"MethodExpression","replacement":"line.text.endsWith('@')","status":"Survived","static":false,"testsCompleted":7,"coveredBy":["191","192","193","195","196","197","202"],"location":{"end":{"column":72,"line":95},"start":{"column":47,"line":95}}},{"id":"69","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":7,"coveredBy":["191","192","193","195","196","197","202"],"location":{"end":{"column":71,"line":95},"start":{"column":68,"line":95}}},{"id":"70","mutatorName":"BooleanLiteral","replacement":"hasTags","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":15,"line":96},"start":{"column":7,"line":96}}},{"id":"71","mutatorName":"ConditionalExpression","replacement":"true","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":15,"line":96},"start":{"column":7,"line":96}}},{"id":"72","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":15,"line":96},"start":{"column":7,"line":96}}},{"id":"73","mutatorName":"BlockStatement","replacement":"{}","status":"Survived","static":false,"testsCompleted":7,"coveredBy":["190","194","195","198","199","200","201"],"location":{"end":{"column":4,"line":98},"start":{"column":17,"line":96}}},{"id":"74","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["190"],"coveredBy":["190","194","195","198","199","200","201"],"location":{"end":{"column":48,"line":97},"start":{"column":12,"line":97}}},{"id":"75","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected 515 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["190"],"coveredBy":["190","194","195","198","199","200","201"],"location":{"end":{"column":46,"line":97},"start":{"column":20,"line":97}}},{"id":"76","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":6,"coveredBy":["191","192","193","196","197","202"],"location":{"end":{"column":41,"line":101},"start":{"column":38,"line":101}}},{"id":"77","mutatorName":"ConditionalExpression","replacement":"true","status":"Survived","static":false,"testsCompleted":6,"coveredBy":["191","192","193","196","197","202"],"location":{"end":{"column":22,"line":102},"start":{"column":7,"line":102}}},{"id":"78","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":6,"coveredBy":["191","192","193","196","197","202"],"location":{"end":{"column":22,"line":102},"start":{"column":7,"line":102}}},{"id":"79","mutatorName":"EqualityOperator","replacement":"spaceIdx !== -1","status":"Survived","static":false,"testsCompleted":6,"coveredBy":["191","192","193","196","197","202"],"location":{"end":{"column":22,"line":102},"start":{"column":7,"line":102}}},{"id":"80","mutatorName":"UnaryOperator","replacement":"+1","status":"Survived","static":false,"testsCompleted":6,"coveredBy":["191","192","193","196","197","202"],"location":{"end":{"column":22,"line":102},"start":{"column":20,"line":102}}},{"id":"81","mutatorName":"BlockStatement","replacement":"{}","status":"Survived","static":false,"testsCompleted":1,"coveredBy":["196"],"location":{"end":{"column":4,"line":105},"start":{"column":24,"line":102}}},{"id":"82","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["196"],"coveredBy":["196"],"location":{"end":{"column":48,"line":104},"start":{"column":12,"line":104}}},{"id":"83","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected 604 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["196"],"coveredBy":["196"],"location":{"end":{"column":46,"line":104},"start":{"column":20,"line":104}}},{"id":"84","mutatorName":"MethodExpression","replacement":"line.text","status":"Survived","static":false,"testsCompleted":5,"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":54,"line":106},"start":{"column":22,"line":106}}},{"id":"85","mutatorName":"ArithmeticOperator","replacement":"spaceIdx - 1","statusReason":"expected false to be true // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["191"],"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":53,"line":106},"start":{"column":41,"line":106}}},{"id":"86","mutatorName":"MethodExpression","replacement":"line.text","status":"Survived","static":false,"testsCompleted":5,"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":45,"line":107},"start":{"column":16,"line":107}}},{"id":"87","mutatorName":"ArithmeticOperator","replacement":"spaceIdx - 1","status":"Survived","static":false,"testsCompleted":5,"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":44,"line":107},"start":{"column":32,"line":107}}},{"id":"88","mutatorName":"ArithmeticOperator","replacement":"budget + tagSection.length","statusReason":"expected 542 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["191"],"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":48,"line":108},"start":{"column":22,"line":108}}},{"id":"89","mutatorName":"ConditionalExpression","replacement":"true","status":"Survived","static":false,"testsCompleted":5,"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":21,"line":109},"start":{"column":7,"line":109}}},{"id":"90","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected 702 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["197"],"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":21,"line":109},"start":{"column":7,"line":109}}},{"id":"91","mutatorName":"EqualityOperator","replacement":"bodyBudget <"+"= 0","status":"Survived","static":false,"testsCompleted":5,"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":21,"line":109},"start":{"column":7,"line":109}}},{"id":"92","mutatorName":"EqualityOperator","replacement":"bodyBudget >= 0","statusReason":"expected 702 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["197"],"coveredBy":["191","192","193","197","202"],"location":{"end":{"column":21,"line":109},"start":{"column":7,"line":109}}},{"id":"93","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected 702 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["197"],"coveredBy":["197"],"location":{"end":{"column":4,"line":114},"start":{"column":23,"line":109}}},{"id":"94","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["197"],"coveredBy":["197"],"location":{"end":{"column":48,"line":113},"start":{"column":12,"line":113}}},{"id":"95","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected 725 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["197"],"coveredBy":["197"],"location":{"end":{"column":46,"line":113},"start":{"column":20,"line":113}}},{"id":"96","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["191"],"coveredBy":["191","192","193","202"],"location":{"end":{"column":63,"line":115},"start":{"column":10,"line":115}}},{"id":"97","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected +0 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["191"],"coveredBy":["191","192","193","202"],"location":{"end":{"column":61,"line":115},"start":{"column":18,"line":115}}},{"id":"98","mutatorName":"MethodExpression","replacement":"body","statusReason":"expected 542 to be 510 // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["191"],"coveredBy":["191","192","193","202"],"location":{"end":{"column":59,"line":115},"start":{"column":34,"line":115}}},{"id":"99","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be { Object (text) } // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["135"],"coveredBy":["135","136","137","138","139","140","141","142","143","144","145","146","147","148","149","150","151","152","153","154","155","156","157","158","159","160","161","162","163"],"location":{"end":{"column":2,"line":166},"start":{"column":85,"line":143}}},{"id":"100","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["136"],"coveredBy":["135","136","137","138","139","140","141","142","143","144","145","146","147","148","149","150","151","152","153","154","155","156","157","158","159","160","161","162","163"],"location":{"end":{"column":31,"line":144},"start":{"column":7,"line":144}}},{"id":"101","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { text: ':alice PRIVMSG #foo :hi' } to be { Object (text) } // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["135"],"coveredBy":["135","136","137","138","139","140","141","142","143","144","145","146","147","148","149","150","151","152","153","154","155","156","157","158","159","160","161","162","163"],"location":{"end":{"column":31,"line":144},"start":{"column":7,"line":144}}},{"id":"102","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { text: ':alice PRIVMSG #foo :hi' } to be { Object (text) } // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["135"],"coveredBy":["135","136","137","138","139","140","141","142","143","144","145","146","147","148","149","150","151","152","153","154","155","156","157","158","159","160","161","162","163"],"location":{"end":{"column":30,"line":144},"start":{"column":16,"line":144}}},{"id":"103","mutatorName":"BooleanLiteral","replacement":"line.text.startsWith('@')","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","141","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":33,"line":145},"start":{"column":7,"line":145}}},{"id":"104","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","141","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":33,"line":145},"start":{"column":7,"line":145}}},{"id":"105","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":24,"coveredBy":["136","137","138","139","140","141","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":33,"line":145},"start":{"column":7,"line":145}}},{"id":"106","mutatorName":"MethodExpression","replacement":"line.text.endsWith('@')","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","141","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":33,"line":145},"start":{"column":8,"line":145}}},{"id":"107","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":24,"coveredBy":["136","137","138","139","140","141","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":32,"line":145},"start":{"column":29,"line":145}}},{"id":"108","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":41,"line":146},"start":{"column":38,"line":146}}},{"id":"109","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":22,"line":147},"start":{"column":7,"line":147}}},{"id":"110","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { text: '@+typing' } to be { text: '@+typing' } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { text: '@+typing' }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":6,"killedBy":["142"],"coveredBy":["136","137","138","139","140","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":22,"line":147},"start":{"column":7,"line":147}}},{"id":"111","mutatorName":"EqualityOperator","replacement":"spaceIdx !== -1","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":22,"line":147},"start":{"column":7,"line":147}}},{"id":"112","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected { text: '@+typing' } to be { text: '@+typing' } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { text: '@+typing' }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":6,"killedBy":["142"],"coveredBy":["136","137","138","139","140","142","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":22,"line":147},"start":{"column":20,"line":147}}},{"id":"113","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":50,"line":149},"start":{"column":22,"line":149}}},{"id":"114","mutatorName":"MethodExpression","replacement":"line.text","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":45,"line":150},"start":{"column":16,"line":150}}},{"id":"115","mutatorName":"ArithmeticOperator","replacement":"spaceIdx - 1","statusReason":"expected '1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":44,"line":150},"start":{"column":32,"line":150}}},{"id":"116","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@t;y;p;i;n;g;=;1 :alice PRIVMSG #foo …' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":39,"line":151},"start":{"column":36,"line":151}}},{"id":"117","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":8,"killedBy":["146"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":52,"line":153},"start":{"column":38,"line":153}}},{"id":"118","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/read-marker=m42 :alice TAGMSG…' // Object.is equality","status":"Killed","static":false,"testsCompleted":14,"killedBy":["153"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":61,"line":154},"start":{"column":42,"line":154}}},{"id":"119","mutatorName":"MethodExpression","replacement":"entries.every(entry => clientTagKey(entry).startsWith('+'))","statusReason":"expected '@account=alice;+typing=1 :alice PRIVM…' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["138"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":84,"line":155},"start":{"column":24,"line":155}}},{"id":"120","mutatorName":"ArrowFunction","replacement":"() => undefined","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":83,"line":155},"start":{"column":37,"line":155}}},{"id":"121","mutatorName":"MethodExpression","replacement":"clientTagKey(entry).endsWith('+')","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":83,"line":155},"start":{"column":48,"line":155}}},{"id":"122","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { Object (text) } to be { Object (text) } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { Object (text) }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":5,"killedBy":["140"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":82,"line":155},"start":{"column":79,"line":155}}},{"id":"123","mutatorName":"BooleanLiteral","replacement":"hasClientTag","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":20,"line":156},"start":{"column":7,"line":156}}},{"id":"124","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":20,"line":156},"start":{"column":7,"line":156}}},{"id":"125","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { Object (text) } to be { Object (text) } // Object.is equality\n\nIf it should pass with deep equality, replace \"toBe\" with \"toStrictEqual\"\n\nExpected: { Object (text) }\nReceived: serializes to the same string\n","status":"Killed","static":false,"testsCompleted":5,"killedBy":["140"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":20,"line":156},"start":{"column":7,"line":156}}},{"id":"126","mutatorName":"MethodExpression","replacement":"entries","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":5,"line":163},"start":{"column":16,"line":158}}},{"id":"127","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected ':alice PRIVMSG #foo :hi' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["138"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":4,"line":163},"start":{"column":42,"line":158}}},{"id":"128","mutatorName":"BooleanLiteral","replacement":"key.startsWith('+')","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":29,"line":160},"start":{"column":9,"line":160}}},{"id":"129","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":29,"line":160},"start":{"column":9,"line":160}}},{"id":"130","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':alice PRIVMSG #foo :hi' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["138"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":29,"line":160},"start":{"column":9,"line":160}}},{"id":"131","mutatorName":"MethodExpression","replacement":"key.endsWith('+')","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":29,"line":160},"start":{"column":10,"line":160}}},{"id":"132","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':alice PRIVMSG #foo :hi' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["138"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":28,"line":160},"start":{"column":25,"line":160}}},{"id":"133","mutatorName":"BooleanLiteral","replacement":"false","statusReason":"expected ':alice PRIVMSG #foo :hi' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["138"],"coveredBy":["138","139","145","149","157","162"],"location":{"end":{"column":42,"line":160},"start":{"column":38,"line":160}}},{"id":"134","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":53,"line":161},"start":{"column":9,"line":161}}},{"id":"135","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":7,"killedBy":["146"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":53,"line":161},"start":{"column":9,"line":161}}},{"id":"136","mutatorName":"LogicalOperator","replacement":"typingWhitelisted || key === '+draft/typing'","statusReason":"expected '@+typing=1;+draft/typing=active :alic…' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":9,"killedBy":["148"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":53,"line":161},"start":{"column":9,"line":161}}},{"id":"137","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1;+draft/typing=active :alic…' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["148"],"coveredBy":["146","147","148","149","152","156","159"],"location":{"end":{"column":53,"line":161},"start":{"column":30,"line":161}}},{"id":"138","mutatorName":"EqualityOperator","replacement":"key !== '+draft/typing'","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["146"],"coveredBy":["146","147","148","149","152","156","159"],"location":{"end":{"column":53,"line":161},"start":{"column":30,"line":161}}},{"id":"139","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["146"],"coveredBy":["146","147","148","149","152","156","159"],"location":{"end":{"column":53,"line":161},"start":{"column":38,"line":161}}},{"id":"140","mutatorName":"BooleanLiteral","replacement":"false","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["146"],"coveredBy":["146","147","148","149","152","156","159"],"location":{"end":{"column":66,"line":161},"start":{"column":62,"line":161}}},{"id":"141","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","148","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":65,"line":162},"start":{"column":12,"line":162}}},{"id":"142","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/read-marker=m42 :alice TAGMSG…' // Object.is equality","status":"Killed","static":false,"testsCompleted":10,"killedBy":["153"],"coveredBy":["136","137","138","139","143","145","148","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":65,"line":162},"start":{"column":12,"line":162}}},{"id":"143","mutatorName":"LogicalOperator","replacement":"readMarkerWhitelisted || key === '+draft/read-marker'","statusReason":"expected '@+foo=bar;+draft/read-marker=m1 :alic…' to be '@+draft/read-marker=m1 :alice TAGMSG …' // Object.is equality","status":"Killed","static":false,"testsCompleted":12,"killedBy":["155"],"coveredBy":["136","137","138","139","143","145","148","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":65,"line":162},"start":{"column":12,"line":162}}},{"id":"144","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@+foo=bar;+draft/read-marker=m1 :alic…' to be '@+draft/read-marker=m1 :alice TAGMSG …' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["155"],"coveredBy":["153","154","155","156","157"],"location":{"end":{"column":65,"line":162},"start":{"column":37,"line":162}}},{"id":"145","mutatorName":"EqualityOperator","replacement":"key !== '+draft/read-marker'","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/read-marker=m42 :alice TAGMSG…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["153"],"coveredBy":["153","154","155","156","157"],"location":{"end":{"column":65,"line":162},"start":{"column":37,"line":162}}},{"id":"146","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/read-marker=m42 :alice TAGMSG…' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["153"],"coveredBy":["153","154","155","156","157"],"location":{"end":{"column":65,"line":162},"start":{"column":45,"line":162}}},{"id":"147","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected ':alice PRIVMSG #foo :hi' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["138"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":24,"line":164},"start":{"column":7,"line":164}}},{"id":"148","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected '@ :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":24,"line":164},"start":{"column":7,"line":164}}},{"id":"149","mutatorName":"EqualityOperator","replacement":"kept.length !== 0","statusReason":"expected '@ :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":24,"line":164},"start":{"column":7,"line":164}}},{"id":"150","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","143","150","158","163"],"location":{"end":{"column":47,"line":164},"start":{"column":33,"line":164}}},{"id":"151","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected undefined to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["138"],"coveredBy":["138","139","145","146","147","148","149","152","153","154","155","156","157","159","162"],"location":{"end":{"column":48,"line":165},"start":{"column":10,"line":165}}},{"id":"152","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to be '@account=alice :alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["138"],"coveredBy":["138","139","145","146","147","148","149","152","153","154","155","156","157","159","162"],"location":{"end":{"column":46,"line":165},"start":{"column":18,"line":165}}},{"id":"153","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@msgid=abc+draft/typing=active :alice…' to be '@msgid=abc;+draft/typing=active :alic…' // Object.is equality","status":"Killed","static":false,"testsCompleted":7,"killedBy":["149"],"coveredBy":["138","139","145","146","147","148","149","152","153","154","155","156","157","159","162"],"location":{"end":{"column":35,"line":165},"start":{"column":32,"line":165}}},{"id":"154","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'startsWith')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":2,"line":172},"start":{"column":46,"line":169}}},{"id":"155","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@+typing=1 :alice PRIVMSG #foo :hi' to be ':alice PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["136"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":31,"line":170},"start":{"column":28,"line":170}}},{"id":"156","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":8,"killedBy":["146"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":19,"line":171},"start":{"column":10,"line":171}}},{"id":"157","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing :alice TAGMSG #foo' // Object.is equality","status":"Killed","static":false,"testsCompleted":9,"killedBy":["147"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":19,"line":171},"start":{"column":10,"line":171}}},{"id":"158","mutatorName":"EqualityOperator","replacement":"eq !== -1","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":8,"killedBy":["146"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":19,"line":171},"start":{"column":10,"line":171}}},{"id":"159","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing :alice TAGMSG #foo' // Object.is equality","status":"Killed","static":false,"testsCompleted":9,"killedBy":["147"],"coveredBy":["136","137","138","139","140","143","145","146","147","148","149","150","152","153","154","155","156","157","158","159","162","163"],"location":{"end":{"column":19,"line":171},"start":{"column":17,"line":171}}},{"id":"160","mutatorName":"MethodExpression","replacement":"entry","statusReason":"expected ':alice TAGMSG #foo' to be '@+draft/typing=active :alice TAGMSG #…' // Object.is equality","status":"Killed","static":false,"testsCompleted":7,"killedBy":["146"],"coveredBy":["136","138","139","140","143","145","146","148","149","150","152","153","155","156","157","158","159","162","163"],"location":{"end":{"column":48,"line":171},"start":{"column":30,"line":171}}},{"id":"161","mutatorName":"BlockStatement","replacement":"{}","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":2,"line":177},"start":{"column":61,"line":175}}},{"id":"162","mutatorName":"ConditionalExpression","replacement":"true","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":56,"line":176},"start":{"column":10,"line":176}}},{"id":"163","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":56,"line":176},"start":{"column":10,"line":176}}},{"id":"164","mutatorName":"LogicalOperator","replacement":"mode === 'spec-text' && mode === 'spec-binary'","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":56,"line":176},"start":{"column":10,"line":176}}},{"id":"165","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":30,"line":176},"start":{"column":10,"line":176}}},{"id":"166","mutatorName":"EqualityOperator","replacement":"mode !== 'spec-text'","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":30,"line":176},"start":{"column":10,"line":176}}},{"id":"167","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":15,"coveredBy":["188","189","190","191","192","193","194","195","196","197","198","199","200","201","202"],"location":{"end":{"column":30,"line":176},"start":{"column":19,"line":176}}},{"id":"168","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["188","189","190","191","192","193","194","195","196","197","199","200","201"],"location":{"end":{"column":56,"line":176},"start":{"column":34,"line":176}}},{"id":"169","mutatorName":"EqualityOperator","replacement":"mode !== 'spec-binary'","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["188","189","190","191","192","193","194","195","196","197","199","200","201"],"location":{"end":{"column":56,"line":176},"start":{"column":34,"line":176}}},{"id":"170","mutatorName":"StringLiteral","replacement":"\"\"","status":"Survived","static":false,"testsCompleted":13,"coveredBy":["188","189","190","191","192","193","194","195","196","197","199","200","201"],"location":{"end":{"column":56,"line":176},"start":{"column":43,"line":176}}}],"source":"/**\n * Per-recipient outbound transformations for IRCv3 capabilities.\n *\n * Reducers emit {@link RawLine} strings already formatted; they cannot know\n * which caps each downstream recipient has negotiated. These pure helpers\n * are called by the dispatch / runtime layer when delivering lines to a\n * specific connection, applying (or omitting) the IRCv3 `server-time` and\n * `message-tags` decorations according to that recipient's cap set.\n *\n * Spec references:\n * - IRCv3 Server-Time: the time tag is `time` and uses ISO-8601 with\n * millisecond precision (`yyyy-MM-ddTHH:mm:ss.sssZ`).\n * - IRCv3 Message Tags: the 512-byte IRC line limit INCLUDES the tag\n * section for clients that negotiated `message-tags`. Legacy clients\n * never see tags so the budget applies to the body only.\n */\n\nimport type { RawLine } from '../effects.js';\nimport { MAX_WS_MESSAGE_BYTES, type WsFrameMode } from '../ws-framing.js';\n\n/**\n * RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing\n * CR-LF. Adapters append `\\r\\n` after the lines produced here, so the\n * budget for the {@link RawLine} text itself is 510 bytes. We retain the\n * 512 symbolic constant because every other place that enforces the limit\n * (CAP LS splitting, MOTD wrapping) does the same subtraction locally.\n */\nexport const MAX_LINE_BYTES = 512;\n\n/** Length of the trailing CR-LF the wire layer appends to each line. */\nconst TRAILING_CRLF_LEN = 2;\n\n/**\n * Formats epoch milliseconds as the IRCv3 Server-Time tag value:\n * `yyyy-MM-ddTHH:mm:ss.sssZ` (ISO-8601, UTC, millisecond precision).\n *\n * Implemented via `Date#toISOString`, which is the canonical form required\n * by the spec and already zero-pads every field.\n */\nexport function formatServerTime(now: number): string {\n return new Date(now).toISOString();\n}\n\n/**\n * Prepends the `@time=...` server-time tag to `line.text` iff the recipient\n * negotiated the `server-time` cap. Returns the original line object\n * unchanged otherwise.\n *\n * The returned {@link RawLine} is always a fresh object so callers never\n * observe accidental mutation of the input.\n *\n * Note: when `line.text` already begins with a tag section (`@...`), the\n * `time` tag is merged into the existing section so we never emit two `@`\n * prefixes. This matches how the dispatch layer composes per-recipient\n * decorations from multiple cap helpers.\n */\nexport function applyServerTime(line: RawLine, caps: ReadonlySet<"+"string>, now: number): RawLine {\n if (!caps.has('server-time')) return line;\n const stamp = formatServerTime(now);\n if (line.text.startsWith('@')) {\n // Insert `time=<"+"stamp>;` immediately after the leading `@`.\n const merged = `@time=${stamp};${line.text.slice(1)}`;\n return { text: merged };\n }\n return { text: `@time=${stamp} ${line.text}` };\n}\n\n/**\n * Enforces the IRC line-length budget on `line.text`.\n *\n * For recipients that negotiated `message-tags`, the tag section (the\n * leading `@... ` prefix) is part of the wire budget and is preserved\n * intact; truncation happens at the end of the trailing parameter only.\n * For legacy recipients the entire `line.text` is the body and may be\n * truncated from the right.\n *\n * The optional `mode` selects which byte budget applies: an IRCv3\n * WebSocket spec mode (`spec-text` / `spec-binary`) caps the message at\n * {@link MAX_WS_MESSAGE_BYTES} (510) directly, because WebSocket messages\n * carry no trailing CR-LF; legacy mode (the default) caps at\n * `MAX_LINE_BYTES - 2` (512 − 2 = 510). Both resolve to 510 today, but the\n * mode-aware call sites need no edits if either constant ever changes.\n *\n * The returned {@link RawLine} is the original object when no truncation\n * was needed, otherwise a fresh object with the truncated text.\n */\nexport function enforceLineLimit(\n line: RawLine,\n caps: ReadonlySet<"+"string>,\n mode?: WsFrameMode,\n): RawLine {\n const budget = isSpecMode(mode) ? MAX_WS_MESSAGE_BYTES : MAX_LINE_BYTES - TRAILING_CRLF_LEN;\n if (line.text.length <"+"= budget) return line;\n\n const hasTags = caps.has('message-tags') && line.text.startsWith('@');\n if (!hasTags) {\n return { text: line.text.slice(0, budget) };\n }\n\n // Preserve the tag section verbatim; truncate only the post-tag body.\n const spaceIdx = line.text.indexOf(' ');\n if (spaceIdx === -1) {\n // Malformed: a `@`-prefixed line with no space. Truncate from the right.\n return { text: line.text.slice(0, budget) };\n }\n const tagSection = line.text.slice(0, spaceIdx + 1); // include trailing space\n const body = line.text.slice(spaceIdx + 1);\n const bodyBudget = budget - tagSection.length;\n if (bodyBudget <"+" 0) {\n // Pathological: the tag section alone exceeds the budget. Drop the body\n // entirely and truncate the tag section itself so the wire contract\n // (≤ budget bytes) still holds.\n return { text: line.text.slice(0, budget) };\n }\n return { text: `${tagSection}${body.slice(0, bodyBudget)}` };\n}\n\n/**\n * Strips IRCv3 message-tags client-only tags (keys prefixed with `+`) from\n * the line's tag section when the recipient did NOT negotiate the\n * `message-tags` capability. Recipients that negotiated `message-tags`\n * receive the line verbatim.\n *\n * Per the IRCv3 Message Tags spec, `+`-prefixed tags are client-only and\n * MUST NOT be delivered to clients that lack the `message-tags` cap.\n * Server-defined tags (no `+` prefix) are left in place — each is gated by\n * its own capability (server-time, account-tag, …) applied elsewhere.\n *\n * Spec exception: IRCv3 `draft/typing` carves out a whitelist for the\n * `+draft/typing` client tag. A recipient that announced `draft/typing`\n * (but NOT `message-tags`) still receives `+draft/typing` so typing\n * indicators work without the broader message-tags cap. `draft/read-marker`\n * carves out the same exception for its `+draft/read-marker` tag so a\n * read-marker update reaches the user's other connections. Every other\n * client tag remains stripped for such recipients.\n *\n * When filtering removes every tag, the leading `@... ` section is dropped\n * entirely so legacy clients never observe a bare tag prefix.\n *\n * The input {@link RawLine} is never mutated; the original object is\n * returned unchanged when no client tag needs removing.\n */\nexport function filterClientTags(line: RawLine, caps: ReadonlySet<"+"string>): RawLine {\n if (caps.has('message-tags')) return line;\n if (!line.text.startsWith('@')) return line;\n const spaceIdx = line.text.indexOf(' ');\n if (spaceIdx === -1) return line;\n\n const tagSection = line.text.slice(1, spaceIdx);\n const rest = line.text.slice(spaceIdx + 1);\n const entries = tagSection.split(';');\n\n const typingWhitelisted = caps.has('draft/typing');\n const readMarkerWhitelisted = caps.has('draft/read-marker');\n const hasClientTag = entries.some((entry) => clientTagKey(entry).startsWith('+'));\n if (!hasClientTag) return line;\n\n const kept = entries.filter((entry) => {\n const key = clientTagKey(entry);\n if (!key.startsWith('+')) return true;\n if (typingWhitelisted && key === '+draft/typing') return true;\n return readMarkerWhitelisted && key === '+draft/read-marker';\n });\n if (kept.length === 0) return { text: rest };\n return { text: `@${kept.join(';')} ${rest}` };\n}\n\n/** Returns the key portion of a raw `key` or `key=value` tag entry. */\nfunction clientTagKey(entry: string): string {\n const eq = entry.indexOf('=');\n return eq === -1 ? entry : entry.slice(0, eq);\n}\n\n/** True when `mode` is an IRCv3 WebSocket spec framing mode (not legacy/absent). */\nfunction isSpecMode(mode: WsFrameMode | undefined): boolean {\n return mode === 'spec-text' || mode === 'spec-binary';\n}\n"},"src/protocol/parser.ts":{"language":"typescript","mutants":[{"id":"171","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '' to be 'IrcParseError' // Object.is equality","status":"Killed","static":false,"testsCompleted":7,"killedBy":["86"],"coveredBy":["80","81","82","83","84","85","86","89","90","91","92","93","94"],"location":{"end":{"column":32,"line":15},"start":{"column":17,"line":15}}},{"id":"178","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":2,"line":59},"start":{"column":57,"line":46}}},{"id":"179","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":15,"line":47},"start":{"column":13,"line":47}}},{"id":"180","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected '' to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":35,"line":48},"start":{"column":19,"line":48}}},{"id":"182","mutatorName":"EqualityOperator","replacement":"i >= value.length","statusReason":"expected '' to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":35,"line":48},"start":{"column":19,"line":48}}},{"id":"181","mutatorName":"EqualityOperator","replacement":"i <"+"= value.length","status":"Survived","static":false,"testsCompleted":22,"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":35,"line":48},"start":{"column":19,"line":48}}},{"id":"183","mutatorName":"UpdateOperator","replacement":"i--","statusReason":"Hit limit reached (707001/707000)","status":"Timeout","static":false,"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":40,"line":48},"start":{"column":37,"line":48}}},{"id":"184","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Property failed after 1 tests\n{ seed: -2122895866, path: \"0:1:1:1:2:2:2:2:2:2:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{\"a\":\"0\"},\"command\":\"000\",\"params\":[]}]\nShrunk 10 time(s)\nGot AssertionError: expected { tags: { a: '' }, …(2) } to deeply equal { tags: { a: '0' }, …(2) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":4,"line":57},"start":{"column":42,"line":48}}},{"id":"185","mutatorName":"MethodExpression","replacement":"value","statusReason":"expected '\\:\\:' to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":31,"line":49},"start":{"column":16,"line":49}}},{"id":"186","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Property failed after 1 tests\n{ seed: -32671423, path: \"0:1:2:1:2:2:2:2:2:2:2:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{\"0\":\"0\"},\"command\":\"000\",\"params\":[]}]\nShrunk 11 time(s)\nGot AssertionError: expected { tags: { '0': '' }, …(2) } to deeply equal { tags: { '0': '0' }, …(2) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":44,"line":50},"start":{"column":9,"line":50}}},{"id":"187","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected '\\:' to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":44,"line":50},"start":{"column":9,"line":50}}},{"id":"189","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '\\b;\\d\\r\\f' to be 'a b;c\\d\\re\\nf' // Object.is equality","status":"Killed","static":false,"testsCompleted":6,"killedBy":["175"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":20,"line":50},"start":{"column":9,"line":50}}},{"id":"188","mutatorName":"LogicalOperator","replacement":"ch === '\\\\' || i + 1 <"+" value.length","statusReason":"Property failed after 3 tests\n{ seed: 155353055, path: \"2:1:1:1:2:1:3:3:3:3:3:3\", endOnFailure: true }\nCounterexample: [{\"tags\":{\"0\":\"aA\"},\"command\":\"000\",\"params\":[]}]\nShrunk 11 time(s)\nGot AssertionError: expected { tags: { '0': 'A' }, …(2) } to deeply equal { tags: { '0': 'aA' }, …(2) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":44,"line":50},"start":{"column":9,"line":50}}},{"id":"190","mutatorName":"EqualityOperator","replacement":"ch !== '\\\\'","statusReason":"expected '\\:' to be ';' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["170"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":20,"line":50},"start":{"column":9,"line":50}}},{"id":"191","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"Property failed after 1 tests\n{ seed: -214176654, path: \"0:1:1:2:2:2:2:2:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{\"a\":\";\"},\"command\":\"000\",\"params\":[]}]\nShrunk 8 time(s)\nGot AssertionError: expected { tags: { a: '\\:' }, …(2) } to deeply equal { tags: { a: ';' }, …(2) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","170","171","172","173","174","175","176","177","203","204","220"],"location":{"end":{"column":20,"line":50},"start":{"column":16,"line":50}}},{"id":"192","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected 'foo' to be 'foo\\' // Object.is equality","status":"Killed","static":false,"testsCompleted":13,"killedBy":["100"],"coveredBy":["71","72","73","74","100","101","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":44,"line":50},"start":{"column":24,"line":50}}},{"id":"194","mutatorName":"EqualityOperator","replacement":"i + 1 >= value.length","statusReason":"expected { a: 'foo\\:bar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","100","101","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":44,"line":50},"start":{"column":24,"line":50}}},{"id":"195","mutatorName":"ArithmeticOperator","replacement":"i - 1","statusReason":"expected 'foo' to be 'foo\\' // Object.is equality","status":"Killed","static":false,"testsCompleted":5,"killedBy":["100"],"coveredBy":["71","72","73","74","100","101","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":29,"line":50},"start":{"column":24,"line":50}}},{"id":"196","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { a: 'foo:bar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":6,"line":54},"start":{"column":46,"line":50}}},{"id":"197","mutatorName":"MethodExpression","replacement":"value","statusReason":"expected { a: 'foofoo\\:barbar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":39,"line":51},"start":{"column":20,"line":51}}},{"id":"193","mutatorName":"EqualityOperator","replacement":"i + 1 <"+"= value.length","statusReason":"expected 'foo' to be 'foo\\' // Object.is equality","status":"Killed","static":false,"testsCompleted":15,"killedBy":["100"],"coveredBy":["71","72","73","74","100","101","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":44,"line":50},"start":{"column":24,"line":50}}},{"id":"198","mutatorName":"ArithmeticOperator","replacement":"i - 1","statusReason":"expected { a: 'fooobar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":38,"line":51},"start":{"column":33,"line":51}}},{"id":"199","mutatorName":"AssignmentOperator","replacement":"out -= TAG_ESCAPES[next] ?? next","statusReason":"expected { a: 'NaNbar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":39,"line":52},"start":{"column":7,"line":52}}},{"id":"200","mutatorName":"LogicalOperator","replacement":"TAG_ESCAPES[next] && next","statusReason":"expected { a: 'foo:bar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["71"],"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":39,"line":52},"start":{"column":14,"line":52}}},{"id":"202","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { time: '', account: '' } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","175","176","177","203","204","220"],"location":{"end":{"column":6,"line":56},"start":{"column":12,"line":54}}},{"id":"201","mutatorName":"UpdateOperator","replacement":"i--","statusReason":"Hit limit reached (167701/167700)","status":"Timeout","static":false,"coveredBy":["71","72","73","74","102","170","171","172","173","174","175","176","177","203","220"],"location":{"end":{"column":10,"line":53},"start":{"column":7,"line":53}}},{"id":"203","mutatorName":"AssignmentOperator","replacement":"out -= ch","statusReason":"expected { time: NaN, account: NaN } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","175","176","177","203","204","220"],"location":{"end":{"column":16,"line":55},"start":{"column":7,"line":55}}},{"id":"204","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":2,"line":73},"start":{"column":61,"line":61}}},{"id":"205","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { '0': '', '1': '', '2': '', …(17) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":40,"line":63},"start":{"column":37,"line":63}}},{"id":"206","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected {} to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":4,"line":71},"start":{"column":43,"line":63}}},{"id":"207","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected {} to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":64},"start":{"column":9,"line":64}}},{"id":"208","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { a: 'b', '': '', c: 'd' } to deeply equal { a: 'b', c: 'd' }","status":"Killed","static":false,"testsCompleted":8,"killedBy":["75"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":64},"start":{"column":9,"line":64}}},{"id":"209","mutatorName":"EqualityOperator","replacement":"entry !== ''","statusReason":"expected {} to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":64},"start":{"column":9,"line":64}}},{"id":"210","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { a: 'b', '': '', c: 'd' } to deeply equal { a: 'b', c: 'd' }","status":"Killed","static":false,"testsCompleted":8,"killedBy":["75"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":64},"start":{"column":19,"line":64}}},{"id":"211","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { '': 'ccount=foo' } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":33,"line":65},"start":{"column":30,"line":65}}},{"id":"212","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":18,"line":66},"start":{"column":9,"line":66}}},{"id":"213","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { '+awa': '+away' } to deeply equal { '+away': '' }","status":"Killed","static":false,"testsCompleted":3,"killedBy":["70"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":18,"line":66},"start":{"column":9,"line":66}}},{"id":"214","mutatorName":"EqualityOperator","replacement":"eq !== -1","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":18,"line":66},"start":{"column":9,"line":66}}},{"id":"215","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected { '+awa': '+away' } to deeply equal { '+away': '' }","status":"Killed","static":false,"testsCompleted":3,"killedBy":["70"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":18,"line":66},"start":{"column":16,"line":66}}},{"id":"216","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected {} to deeply equal { '+away': '' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["70"],"coveredBy":["70","203","220"],"location":{"end":{"column":6,"line":68},"start":{"column":20,"line":66}}},{"id":"217","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { '+away': 'Stryker was here!' } to deeply equal { '+away': '' }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["70"],"coveredBy":["70","203","220"],"location":{"end":{"column":23,"line":67},"start":{"column":21,"line":67}}},{"id":"218","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected {} to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":6,"line":70},"start":{"column":12,"line":68}}},{"id":"219","mutatorName":"MethodExpression","replacement":"entry","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":30,"line":69},"start":{"column":12,"line":69}}},{"id":"220","mutatorName":"MethodExpression","replacement":"entry","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":70,"line":69},"start":{"column":51,"line":69}}},{"id":"221","mutatorName":"ArithmeticOperator","replacement":"eq - 1","statusReason":"expected { Object (time, account) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":69,"line":69},"start":{"column":63,"line":69}}},{"id":"222","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":2,"line":98},"start":{"column":46,"line":75}}},{"id":"223","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":33,"line":80},"start":{"column":30,"line":80}}},{"id":"224","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { tags: {}, command: '001', …(2) } to deeply equal { tags: {}, …(3) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["60"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":16,"line":81},"start":{"column":7,"line":81}}},{"id":"225","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":16,"line":81},"start":{"column":7,"line":81}}},{"id":"226","mutatorName":"EqualityOperator","replacement":"at === -1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":16,"line":81},"start":{"column":7,"line":81}}},{"id":"227","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected { tags: {}, command: '001', …(2) } to deeply equal { tags: {}, …(3) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["60"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":16,"line":81},"start":{"column":14,"line":81}}},{"id":"228","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","97","220"],"location":{"end":{"column":4,"line":84},"start":{"column":18,"line":81}}},{"id":"229","mutatorName":"MethodExpression","replacement":"raw","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","97","220"],"location":{"end":{"column":29,"line":82},"start":{"column":12,"line":82}}},{"id":"230","mutatorName":"ArithmeticOperator","replacement":"at - 1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","97","220"],"location":{"end":{"column":28,"line":82},"start":{"column":22,"line":82}}},{"id":"231","mutatorName":"MethodExpression","replacement":"raw","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","97","220"],"location":{"end":{"column":34,"line":83},"start":{"column":18,"line":83}}},{"id":"232","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":38,"line":88},"start":{"column":35,"line":88}}},{"id":"233","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { tags: {}, command: '001', …(2) } to deeply equal { tags: {}, …(3) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["60"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":18,"line":89},"start":{"column":7,"line":89}}},{"id":"234","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":18,"line":89},"start":{"column":7,"line":89}}},{"id":"235","mutatorName":"EqualityOperator","replacement":"bang === -1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":18,"line":89},"start":{"column":7,"line":89}}},{"id":"236","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected { tags: {}, command: '001', …(2) } to deeply equal { tags: {}, …(3) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["60"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":18,"line":89},"start":{"column":16,"line":89}}},{"id":"237","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","96","220"],"location":{"end":{"column":4,"line":92},"start":{"column":20,"line":89}}},{"id":"238","mutatorName":"MethodExpression","replacement":"beforeHost","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","96","220"],"location":{"end":{"column":37,"line":90},"start":{"column":12,"line":90}}},{"id":"239","mutatorName":"MethodExpression","replacement":"beforeHost","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","96","220"],"location":{"end":{"column":38,"line":91},"start":{"column":12,"line":91}}},{"id":"240","mutatorName":"ArithmeticOperator","replacement":"bang - 1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","76","96","220"],"location":{"end":{"column":37,"line":91},"start":{"column":29,"line":91}}},{"id":"241","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":37,"line":94},"start":{"column":29,"line":94}}},{"id":"242","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { name: 'irc.example.com', …(1) } to strictly equal { name: 'irc.example.com' }","status":"Killed","static":false,"testsCompleted":8,"killedBy":["95"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":95},"start":{"column":7,"line":95}}},{"id":"243","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":95},"start":{"column":7,"line":95}}},{"id":"244","mutatorName":"EqualityOperator","replacement":"user === undefined","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":95},"start":{"column":7,"line":95}}},{"id":"245","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { name: 'irc.example.com', …(1) } to strictly equal { name: 'irc.example.com' }","status":"Killed","static":false,"testsCompleted":8,"killedBy":["95"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":96},"start":{"column":7,"line":96}}},{"id":"246","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":96},"start":{"column":7,"line":96}}},{"id":"247","mutatorName":"EqualityOperator","replacement":"host === undefined","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":25,"line":96},"start":{"column":7,"line":96}}},{"id":"254","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":2,"line":176},"start":{"column":49,"line":110}}},{"id":"255","mutatorName":"Regex","replacement":"/[\\r\\n]+/u","statusReason":"expected 'ab' to be 'a\\rb' // Object.is equality","status":"Killed","static":false,"testsCompleted":24,"killedBy":["79"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":44,"line":112},"start":{"column":33,"line":112}}},{"id":"256","mutatorName":"Regex","replacement":"/[\\r\\n]$/u","statusReason":"expected [ 'tok\\r' ] to deeply equal [ 'tok' ]","status":"Killed","static":false,"testsCompleted":22,"killedBy":["77"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":44,"line":112},"start":{"column":33,"line":112}}},{"id":"257","mutatorName":"Regex","replacement":"/[^\\r\\n]+$/u","statusReason":"empty message","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":44,"line":112},"start":{"column":33,"line":112}}},{"id":"258","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected [ 'tokStryker was here!' ] to deeply equal [ 'tok' ]","status":"Killed","static":false,"testsCompleted":22,"killedBy":["77"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":48,"line":112},"start":{"column":46,"line":112}}},{"id":"259","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"empty message","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":28,"line":113},"start":{"column":7,"line":113}}},{"id":"260","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [Function] to throw error including 'empty message' but got 'missing command'","status":"Killed","static":false,"testsCompleted":25,"killedBy":["80"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":28,"line":113},"start":{"column":7,"line":113}}},{"id":"261","mutatorName":"EqualityOperator","replacement":"stripped.length !== 0","statusReason":"empty message","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":28,"line":113},"start":{"column":7,"line":113}}},{"id":"262","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [Function] to throw error including 'empty message' but got 'missing command'","status":"Killed","static":false,"testsCompleted":1,"killedBy":["80"],"coveredBy":["80","86"],"location":{"end":{"column":4,"line":115},"start":{"column":30,"line":113}}},{"id":"263","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected [Function] to throw error including 'empty message' but got ''","status":"Killed","static":false,"testsCompleted":1,"killedBy":["80"],"coveredBy":["80","86"],"location":{"end":{"column":44,"line":114},"start":{"column":29,"line":114}}},{"id":"264","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"message exceeds 512-byte input cap (25 bytes)","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":40,"line":121},"start":{"column":7,"line":121}}},{"id":"265","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":false,"testsCompleted":32,"killedBy":["89"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":40,"line":121},"start":{"column":7,"line":121}}},{"id":"266","mutatorName":"EqualityOperator","replacement":"stripped.length >= MAX_INPUT_BYTES","statusReason":"message exceeds 512-byte input cap (512 bytes)","status":"Killed","static":false,"testsCompleted":30,"killedBy":["87"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":40,"line":121},"start":{"column":7,"line":121}}},{"id":"267","mutatorName":"EqualityOperator","replacement":"stripped.length <"+"= MAX_INPUT_BYTES","statusReason":"message exceeds 512-byte input cap (25 bytes)","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":40,"line":121},"start":{"column":7,"line":121}}},{"id":"268","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":false,"testsCompleted":1,"killedBy":["89"],"coveredBy":["89","90","91"],"location":{"end":{"column":4,"line":126},"start":{"column":42,"line":121}}},{"id":"269","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to match /512/u","status":"Killed","static":false,"testsCompleted":3,"killedBy":["91"],"coveredBy":["89","90","91"],"location":{"end":{"column":85,"line":123},"start":{"column":7,"line":123}}},{"id":"270","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"invalid command token: #FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":131},"start":{"column":7,"line":131}}},{"id":"271","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"invalid command token: @TIME=2024-01-01T00:00:00Z;ACCOUNT=FOO","status":"Killed","static":false,"testsCompleted":13,"killedBy":["68"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":131},"start":{"column":7,"line":131}}},{"id":"272","mutatorName":"MethodExpression","replacement":"rest.endsWith('@')","statusReason":"invalid command token: @TIME=2024-01-01T00:00:00Z;ACCOUNT=FOO","status":"Killed","static":false,"testsCompleted":13,"killedBy":["68"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":131},"start":{"column":7,"line":131}}},{"id":"273","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"invalid command token: #FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","84","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":26,"line":131},"start":{"column":23,"line":131}}},{"id":"274","mutatorName":"BlockStatement","replacement":"{}","statusReason":"invalid command token: @TIME=2024-01-01T00:00:00Z;ACCOUNT=FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":4,"line":138},"start":{"column":29,"line":131}}},{"id":"275","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"invalid command token: TIME=2024-01-01T00:00:00Z;ACCOUNT=FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":35,"line":132},"start":{"column":32,"line":132}}},{"id":"276","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"tag section with no message body","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":133},"start":{"column":9,"line":133}}},{"id":"277","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [Function] to throw error including 'tag section with no message body' but got 'invalid command token: @TIME=NOW'","status":"Killed","static":false,"testsCompleted":10,"killedBy":["84"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":133},"start":{"column":9,"line":133}}},{"id":"278","mutatorName":"EqualityOperator","replacement":"space !== -1","statusReason":"tag section with no message body","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":133},"start":{"column":9,"line":133}}},{"id":"279","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected [Function] to throw error including 'tag section with no message body' but got 'invalid command token: @TIME=NOW'","status":"Killed","static":false,"testsCompleted":10,"killedBy":["84"],"coveredBy":["68","69","70","71","72","73","74","75","76","84","100","101","102","177","203","204","220"],"location":{"end":{"column":21,"line":133},"start":{"column":19,"line":133}}},{"id":"280","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [Function] to throw error including 'tag section with no message body' but got 'invalid command token: @TIME=NOW'","status":"Killed","static":false,"testsCompleted":1,"killedBy":["84"],"coveredBy":["84"],"location":{"end":{"column":6,"line":135},"start":{"column":23,"line":133}}},{"id":"281","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected [Function] to throw error including 'tag section with no message body' but got ''","status":"Killed","static":false,"testsCompleted":1,"killedBy":["84"],"coveredBy":["84"],"location":{"end":{"column":65,"line":134},"start":{"column":31,"line":134}}},{"id":"282","mutatorName":"MethodExpression","replacement":"rest","statusReason":"expected { …(2) } to deeply equal { time: '2024-01-01T00:00:00Z', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":42,"line":136},"start":{"column":22,"line":136}}},{"id":"284","mutatorName":"MethodExpression","replacement":"rest","statusReason":"invalid command token: @TIME=2024-01-01T00:00:00Z;ACCOUNT=FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["68"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":33,"line":137},"start":{"column":12,"line":137}}},{"id":"285","mutatorName":"ArithmeticOperator","replacement":"space - 1","statusReason":"invalid command token: 1","status":"Killed","static":false,"testsCompleted":2,"killedBy":["69"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":32,"line":137},"start":{"column":23,"line":137}}},{"id":"286","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"invalid command token: #FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":141},"start":{"column":7,"line":141}}},{"id":"287","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"invalid command token: :NICK!USER@HOST","status":"Killed","static":false,"testsCompleted":2,"killedBy":["57"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":141},"start":{"column":7,"line":141}}},{"id":"288","mutatorName":"MethodExpression","replacement":"rest.endsWith(':')","statusReason":"invalid command token: :NICK!USER@HOST","status":"Killed","static":false,"testsCompleted":2,"killedBy":["57"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":141},"start":{"column":7,"line":141}}},{"id":"283","mutatorName":"MethodExpression","replacement":"rest.slice(space + 1).trimEnd()","statusReason":"Property failed after 2 tests\n{ seed: 431746715, path: \"1:1:0:1:1:1:1:3:1:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{\"a\":\"\"},\"command\":\"A\",\"params\":[\" \"]}]\nShrunk 10 time(s)\nGot AssertionError: expected { tags: { a: '' }, command: 'A', …(1) } to deeply equal { tags: { a: '' }, command: 'A', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":16,"killedBy":["220"],"coveredBy":["68","69","70","71","72","73","74","75","76","100","101","102","177","203","204","220"],"location":{"end":{"column":45,"line":137},"start":{"column":12,"line":137}}},{"id":"289","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"invalid command token: #FOO","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","82","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":26,"line":141},"start":{"column":23,"line":141}}},{"id":"290","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Property failed after 1 tests\n{ seed: -284835829, path: \"0:0:0:0:0:0:1:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[],\"source\":{\"name\":\"A\"}}]\nShrunk 8 time(s)\nGot IrcParseError: invalid command token: :A\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:308:15)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":4,"line":148},"start":{"column":29,"line":141}}},{"id":"291","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"invalid command token: NICK!USER@HOST","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":35,"line":142},"start":{"column":32,"line":142}}},{"id":"293","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [Function] to throw error including 'source prefix with no command' but got 'invalid command token: :NICK!USER@HOST'","status":"Killed","static":false,"testsCompleted":7,"killedBy":["82"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":21,"line":143},"start":{"column":9,"line":143}}},{"id":"292","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Property failed after 1 tests\n{ seed: -439905452, path: \"0:0:0:0:0:1:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[],\"source\":{\"name\":\"A\"}}]\nShrunk 8 time(s)\nGot IrcParseError: source prefix with no command\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:265:19)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":21,"line":143},"start":{"column":9,"line":143}}},{"id":"294","mutatorName":"EqualityOperator","replacement":"space !== -1","statusReason":"source prefix with no command","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":21,"line":143},"start":{"column":9,"line":143}}},{"id":"296","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [Function] to throw error including 'source prefix with no command' but got 'invalid command token: :NICK!USER@HOST'","status":"Killed","static":false,"testsCompleted":1,"killedBy":["82"],"coveredBy":["82"],"location":{"end":{"column":6,"line":145},"start":{"column":23,"line":143}}},{"id":"297","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected [Function] to throw error including 'source prefix with no command' but got ''","status":"Killed","static":false,"testsCompleted":1,"killedBy":["82"],"coveredBy":["82"],"location":{"end":{"column":62,"line":144},"start":{"column":31,"line":144}}},{"id":"298","mutatorName":"MethodExpression","replacement":"rest","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":46,"line":146},"start":{"column":26,"line":146}}},{"id":"295","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected [Function] to throw error including 'source prefix with no command' but got 'invalid command token: :NICK!USER@HOST'","status":"Killed","static":false,"testsCompleted":9,"killedBy":["82"],"coveredBy":["57","60","62","63","64","76","82","83","95","96","97","99","204","220"],"location":{"end":{"column":21,"line":143},"start":{"column":19,"line":143}}},{"id":"300","mutatorName":"MethodExpression","replacement":"rest","statusReason":"invalid command token: :NICK!USER@HOST","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":33,"line":147},"start":{"column":12,"line":147}}},{"id":"299","mutatorName":"MethodExpression","replacement":"rest.slice(space + 1).trimEnd()","statusReason":"Property failed after 21 tests\n{ seed: -6902502, path: \"20:0:0:0:0:2:0:0:0:2:1:1\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\" \"],\"source\":{\"name\":\"0\"}}]\nShrunk 11 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(2) } to deeply equal { tags: {}, command: '000', …(2) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":12,"killedBy":["220"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":45,"line":147},"start":{"column":12,"line":147}}},{"id":"301","mutatorName":"ArithmeticOperator","replacement":"space - 1","statusReason":"expected { tags: {}, command: 'T', …(2) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["57"],"coveredBy":["57","60","62","63","64","76","83","95","96","97","99","204","220"],"location":{"end":{"column":32,"line":147},"start":{"column":23,"line":147}}},{"id":"303","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [Function] to throw error including 'missing command' but got 'invalid command token: '","status":"Killed","static":false,"testsCompleted":26,"killedBy":["83"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":150},"start":{"column":7,"line":150}}},{"id":"302","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Property failed after 1 tests\n{ seed: -1470315655, path: \"0:0:0:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[]}]\nShrunk 5 time(s)\nGot IrcParseError: missing command\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:277:15)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":150},"start":{"column":7,"line":150}}},{"id":"304","mutatorName":"EqualityOperator","replacement":"rest.length !== 0","statusReason":"missing command","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","83","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":150},"start":{"column":7,"line":150}}},{"id":"305","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected [Function] to throw error including 'missing command' but got 'invalid command token: '","status":"Killed","static":false,"testsCompleted":1,"killedBy":["83"],"coveredBy":["83"],"location":{"end":{"column":4,"line":152},"start":{"column":26,"line":150}}},{"id":"306","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected [Function] to throw error including 'missing command' but got ''","status":"Killed","static":false,"testsCompleted":1,"killedBy":["83"],"coveredBy":["83"],"location":{"end":{"column":46,"line":151},"start":{"column":29,"line":151}}},{"id":"308","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"invalid command token: PRIVMSG #FOO :HELLO WORLD","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":158},"start":{"column":7,"line":158}}},{"id":"307","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"Property failed after 1 tests\n{ seed: 1551694270, path: \"0:0:0:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[]}]\nShrunk 5 time(s)\nGot IrcParseError: invalid command token: \n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:308:15)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":38,"line":155},"start":{"column":35,"line":155}}},{"id":"309","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PON', …(1) } to deeply equal { tags: {}, command: 'PONG', …(1) }","status":"Killed","static":false,"testsCompleted":4,"killedBy":["59"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":158},"start":{"column":7,"line":158}}},{"id":"310","mutatorName":"EqualityOperator","replacement":"firstSpace !== -1","statusReason":"Property failed after 1 tests\n{ seed: 472641541, path: \"0:0:0:0:0:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[]}]\nShrunk 7 time(s)\nGot IrcParseError: invalid command token: 00\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:308:15)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":158},"start":{"column":7,"line":158}}},{"id":"311","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"expected { tags: {}, command: 'PON', …(1) } to deeply equal { tags: {}, command: 'PONG', …(1) }","status":"Killed","static":false,"testsCompleted":4,"killedBy":["59"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":24,"line":158},"start":{"column":22,"line":158}}},{"id":"313","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { tags: {}, command: 'PONG', …(1) } to deeply equal { tags: {}, command: 'PONG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["59"],"coveredBy":["59","177","220"],"location":{"end":{"column":19,"line":160},"start":{"column":17,"line":160}}},{"id":"312","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Property failed after 59 tests\n{ seed: -1364126880, path: \"58:0:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[]}]\nShrunk 4 time(s)\nGot TypeError: Cannot read properties of undefined (reading 'toUpperCase')\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:302:96)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["59","177","220"],"location":{"end":{"column":4,"line":161},"start":{"column":26,"line":158}}},{"id":"314","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'toUpperCase')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":4,"line":164},"start":{"column":10,"line":161}}},{"id":"316","mutatorName":"MethodExpression","replacement":"rest","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":43,"line":163},"start":{"column":17,"line":163}}},{"id":"315","mutatorName":"MethodExpression","replacement":"rest","statusReason":"Property failed after 1 tests\n{ seed: -2111933191, path: \"0:0:0:0:0:1:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"\"]}]\nShrunk 6 time(s)\nGot IrcParseError: invalid command token: 000 :\n at parse (/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/src/protocol/parser.ts:308:15)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:16\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":40,"line":162},"start":{"column":15,"line":162}}},{"id":"317","mutatorName":"ArithmeticOperator","replacement":"firstSpace - 1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":42,"line":163},"start":{"column":28,"line":163}}},{"id":"319","mutatorName":"BooleanLiteral","replacement":"COMMAND_RE.test(command)","statusReason":"invalid command token: PRIVMSG","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":32,"line":167},"start":{"column":7,"line":167}}},{"id":"318","mutatorName":"MethodExpression","replacement":"command.toLowerCase()","statusReason":"Property failed after 4 tests\n{ seed: -763750138, path: \"3:0:0:0:0:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[]}]\nShrunk 5 time(s)\nGot AssertionError: expected { tags: {}, command: 'a', params: [] } to deeply equal { tags: {}, command: 'A', params: [] }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":34,"line":166},"start":{"column":13,"line":166}}},{"id":"320","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"invalid command token: PRIVMSG","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":32,"line":167},"start":{"column":7,"line":167}}},{"id":"322","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":false,"testsCompleted":1,"killedBy":["81"],"coveredBy":["81","85","92","93","94"],"location":{"end":{"column":4,"line":169},"start":{"column":34,"line":167}}},{"id":"323","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected [Function] to throw error including 'invalid command token' but got ''","status":"Killed","static":false,"testsCompleted":1,"killedBy":["81"],"coveredBy":["81","85","92","93","94"],"location":{"end":{"column":64,"line":168},"start":{"column":29,"line":168}}},{"id":"321","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":false,"testsCompleted":26,"killedBy":["81"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","81","85","87","88","92","93","94","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":32,"line":167},"start":{"column":7,"line":167}}},{"id":"324","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected {} to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":56,"line":173},"start":{"column":31,"line":173}}},{"id":"325","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected true to be false // Object.is equality","status":"Killed","static":false,"testsCompleted":30,"killedBy":["98"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":174},"start":{"column":7,"line":174}}},{"id":"326","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["57"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":174},"start":{"column":7,"line":174}}},{"id":"327","mutatorName":"EqualityOperator","replacement":"source === undefined","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, source: { …(3) }, …(2) }","status":"Killed","static":false,"testsCompleted":2,"killedBy":["57"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":27,"line":174},"start":{"column":7,"line":174}}},{"id":"328","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":2,"line":200},"start":{"column":51,"line":178}}},{"id":"329","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":29,"line":179},"start":{"column":7,"line":179}}},{"id":"331","mutatorName":"EqualityOperator","replacement":"remainder.length !== 0","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":29,"line":179},"start":{"column":7,"line":179}}},{"id":"332","mutatorName":"ArrayDeclaration","replacement":"[\"Stryker was here\"]","statusReason":"expected { tags: {}, command: 'PONG', …(1) } to deeply equal { tags: {}, command: 'PONG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["59"],"coveredBy":["59","177","220"],"location":{"end":{"column":40,"line":179},"start":{"column":38,"line":179}}},{"id":"333","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":184},"start":{"column":7,"line":184}}},{"id":"334","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PING', …(1) } to deeply equal { tags: {}, command: 'PING', …(1) }","status":"Killed","static":false,"testsCompleted":3,"killedBy":["58"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":184},"start":{"column":7,"line":184}}},{"id":"330","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":38,"coveredBy":["56","57","58","59","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","177","203","204","220"],"location":{"end":{"column":29,"line":179},"start":{"column":7,"line":179}}},{"id":"335","mutatorName":"MethodExpression","replacement":"remainder.endsWith(':')","statusReason":"expected { tags: {}, command: 'PING', …(1) } to deeply equal { tags: {}, command: 'PING', …(1) }","status":"Killed","static":false,"testsCompleted":3,"killedBy":["58"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":184},"start":{"column":7,"line":184}}},{"id":"337","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["58"],"coveredBy":["58","77","78","220"],"location":{"end":{"column":4,"line":188},"start":{"column":34,"line":184}}},{"id":"336","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"Property failed after 1 tests\n{ seed: -118522860, path: \"0:0:0:0:0:1:1:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"a\",\"\"]}]\nShrunk 8 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":31,"line":184},"start":{"column":28,"line":184}}},{"id":"338","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected { tags: {}, command: 'PING', …(1) } to deeply equal { tags: {}, command: 'PING', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["58"],"coveredBy":["58","77","78","220"],"location":{"end":{"column":16,"line":186},"start":{"column":14,"line":186}}},{"id":"340","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":4,"line":196},"start":{"column":10,"line":188}}},{"id":"339","mutatorName":"MethodExpression","replacement":"remainder","statusReason":"Property failed after 2 tests\n{ seed: 520689456, path: \"1:0:0:0:1:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[\"\"]}]\nShrunk 5 time(s)\nGot AssertionError: expected { tags: {}, command: 'A', …(1) } to deeply equal { tags: {}, command: 'A', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["58","77","78","220"],"location":{"end":{"column":34,"line":187},"start":{"column":16,"line":187}}},{"id":"341","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":50,"line":189},"start":{"column":46,"line":189}}},{"id":"343","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [ '#a', '#b', 'ke', 'a #b key' ] to deeply equal [ '#a', '#b', 'key' ]","status":"Killed","static":false,"testsCompleted":5,"killedBy":["62"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":30,"line":190},"start":{"column":9,"line":190}}},{"id":"342","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Property failed after 1 tests\n{ seed: 967296431, path: \"0:0:0:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"-\",\"\"]}]\nShrunk 4 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":30,"line":190},"start":{"column":9,"line":190}}},{"id":"344","mutatorName":"EqualityOperator","replacement":"trailingMarker !== -1","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":30,"line":190},"start":{"column":9,"line":190}}},{"id":"346","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["62"],"coveredBy":["62","65","99"],"location":{"end":{"column":6,"line":192},"start":{"column":32,"line":190}}},{"id":"345","mutatorName":"UnaryOperator","replacement":"+1","statusReason":"Property failed after 22 tests\n{ seed: -1226594792, path: \"21:0:0:0:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[\"a\",\"\"]}]\nShrunk 5 time(s)\nGot AssertionError: expected { tags: {}, command: 'A', …(1) } to deeply equal { tags: {}, command: 'A', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":30,"line":190},"start":{"column":28,"line":190}}},{"id":"347","mutatorName":"BlockStatement","replacement":"{}","statusReason":"Cannot read properties of undefined (reading 'length')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","63","64","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","100","101","102","203","204","220"],"location":{"end":{"column":6,"line":195},"start":{"column":12,"line":192}}},{"id":"349","mutatorName":"MethodExpression","replacement":"remainder","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","63","64","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","100","101","102","203","204","220"],"location":{"end":{"column":53,"line":194},"start":{"column":18,"line":194}}},{"id":"348","mutatorName":"MethodExpression","replacement":"remainder","statusReason":"Property failed after 1 tests\n{ seed: -375549737, path: \"0:0:0:0:0:1:0:0:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"a\",\"\"]}]\nShrunk 9 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","60","61","63","64","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","100","101","102","203","204","220"],"location":{"end":{"column":50,"line":193},"start":{"column":16,"line":193}}},{"id":"350","mutatorName":"ArithmeticOperator","replacement":"trailingMarker - 2","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","63","64","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","100","101","102","203","204","220"],"location":{"end":{"column":52,"line":194},"start":{"column":34,"line":194}}},{"id":"351","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Property failed after 1 tests\n{ seed: -1047018251, path: \"0:0:0:0:1:0:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"0\",\"\"]}]\nShrunk 7 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":38,"line":198},"start":{"column":19,"line":198}}},{"id":"353","mutatorName":"EqualityOperator","replacement":"middle.length !== 0","statusReason":"Property failed after 1 tests\n{ seed: 1123969938, path: \"0:0:0:0:0:1:0:1:2:2:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"+\",\"a\"]}]\nShrunk 10 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":38,"line":198},"start":{"column":19,"line":198}}},{"id":"352","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":36,"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":38,"line":198},"start":{"column":19,"line":198}}},{"id":"354","mutatorName":"ArrayDeclaration","replacement":"[\"Stryker was here\"]","statusReason":"Property failed after 1 tests\n{ seed: 515982985, path: \"0:0:0:0:0:1:0\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"\"]}]\nShrunk 6 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["58","77","78","220"],"location":{"end":{"column":43,"line":198},"start":{"column":41,"line":198}}},{"id":"356","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"Property failed after 1 tests\n{ seed: 1613368841, path: \"0:0:0:1:0:2:1:1:3:3:3\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"A\",\"params\":[\"_-\",\"0\"]}]\nShrunk 10 time(s)\nGot AssertionError: expected { tags: {}, command: 'A', …(1) } to deeply equal { tags: {}, command: 'A', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":62,"line":198},"start":{"column":59,"line":198}}},{"id":"357","mutatorName":"ArrowFunction","replacement":"() => undefined","statusReason":"Property failed after 1 tests\n{ seed: -123849167, path: \"0:0:0:0:0:1:1:2\", endOnFailure: true }\nCounterexample: [{\"tags\":{},\"command\":\"000\",\"params\":[\"-\",\"\"]}]\nShrunk 7 time(s)\nGot AssertionError: expected { tags: {}, command: '000', …(1) } to deeply equal { tags: {}, command: '000', …(1) }\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:150:39\n at Property.predicate (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.js:14:54)\n at Property.run (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/property/Property.generic.js:46:33)\n at runIt (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:18:30)\n at check (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:62:11)\n at Module.assert (file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/runner/Runner.js:65:17)\n at /Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core/.stryker-tmp/sandbox-ITAKl6/tests/serializer.test.ts:148:8\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n at file:///Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/node_modules/.pnpm/@vitest+runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n\nHint: Enable verbose mode in order to have the list of all failing values encountered during the run","status":"Killed","static":false,"testsCompleted":1,"killedBy":["220"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":90,"line":198},"start":{"column":71,"line":198}}},{"id":"355","mutatorName":"MethodExpression","replacement":"middle.split(' ')","statusReason":"expected [ '#a', '', '#b' ] to deeply equal [ '#a', '#b' ]","status":"Killed","static":false,"testsCompleted":30,"killedBy":["99"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":91,"line":198},"start":{"column":46,"line":198}}},{"id":"359","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":90,"line":198},"start":{"column":78,"line":198}}},{"id":"360","mutatorName":"EqualityOperator","replacement":"p.length >= 0","statusReason":"expected [ '#a', '', '#b' ] to deeply equal [ '#a', '#b' ]","status":"Killed","static":false,"testsCompleted":27,"killedBy":["99"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":90,"line":198},"start":{"column":78,"line":198}}},{"id":"361","mutatorName":"EqualityOperator","replacement":"p.length <"+"= 0","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":90,"line":198},"start":{"column":78,"line":198}}},{"id":"362","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":199},"start":{"column":10,"line":199}}},{"id":"358","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected [ '#a', '', '#b' ] to deeply equal [ '#a', '#b' ]","status":"Killed","static":false,"testsCompleted":30,"killedBy":["99"],"coveredBy":["56","57","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":90,"line":198},"start":{"column":78,"line":198}}},{"id":"363","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected [ '#a', '#b', 'key', undefined ] to deeply equal [ '#a', '#b', 'key' ]","status":"Killed","static":false,"testsCompleted":6,"killedBy":["62"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":199},"start":{"column":10,"line":199}}},{"id":"364","mutatorName":"EqualityOperator","replacement":"trailing !== undefined","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","62","63","64","65","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","99","100","101","102","203","204","220"],"location":{"end":{"column":32,"line":199},"start":{"column":10,"line":199}}},{"id":"365","mutatorName":"ArrayDeclaration","replacement":"[]","statusReason":"expected { tags: {}, command: 'PRIVMSG', …(1) } to deeply equal { tags: {}, command: 'PRIVMSG', …(1) }","status":"Killed","static":false,"testsCompleted":1,"killedBy":["56"],"coveredBy":["56","57","58","60","61","63","64","66","67","68","69","70","71","72","73","74","75","76","77","78","79","87","88","95","96","97","98","100","101","102","203","204","220"],"location":{"end":{"column":67,"line":199},"start":{"column":45,"line":199}}},{"id":"172","mutatorName":"ObjectLiteral","replacement":"{}","statusReason":"expected { a: 'foo:bar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":true,"testsCompleted":16,"killedBy":["71"],"coveredBy":[],"location":{"end":{"column":2,"line":38},"start":{"column":55,"line":32}}},{"id":"173","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { a: 'foobar' } to deeply equal { a: 'foo;bar' }","status":"Killed","static":true,"testsCompleted":16,"killedBy":["71"],"coveredBy":[],"location":{"end":{"column":11,"line":33},"start":{"column":8,"line":33}}},{"id":"174","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { a: 'foobar' } to deeply equal { a: 'foo bar' }","status":"Killed","static":true,"testsCompleted":17,"killedBy":["72"],"coveredBy":[],"location":{"end":{"column":9,"line":34},"start":{"column":6,"line":34}}},{"id":"175","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected { a: 'ab' } to deeply equal { a: 'a\\b' }","status":"Killed","static":true,"testsCompleted":18,"killedBy":["73"],"coveredBy":[],"location":{"end":{"column":13,"line":35},"start":{"column":9,"line":35}}},{"id":"176","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '' to be '\\r' // Object.is equality","status":"Killed","static":true,"testsCompleted":130,"killedBy":["173"],"coveredBy":[],"location":{"end":{"column":10,"line":36},"start":{"column":6,"line":36}}},{"id":"177","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '' to be '\\n' // Object.is equality","status":"Killed","static":true,"testsCompleted":40,"killedBy":["174"],"coveredBy":[],"location":{"end":{"column":10,"line":37},"start":{"column":6,"line":37}}},{"id":"248","mutatorName":"Regex","replacement":"/(?:[A-Za-z]+|[0-9]{3})$/","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":true,"testsCompleted":124,"killedBy":["92"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}},{"id":"249","mutatorName":"Regex","replacement":"/^(?:[A-Za-z]+|[0-9]{3})/","statusReason":"expected function to throw an error, but it didn't","status":"Killed","static":true,"testsCompleted":37,"killedBy":["92"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}},{"id":"250","mutatorName":"Regex","replacement":"/^(?:[A-Za-z]|[0-9]{3})$/","statusReason":"invalid command token: PRIVMSG","status":"Killed","static":true,"testsCompleted":1,"killedBy":["56"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}},{"id":"251","mutatorName":"Regex","replacement":"/^(?:[^A-Za-z]+|[0-9]{3})$/","statusReason":"invalid command token: PRIVMSG","status":"Killed","static":true,"testsCompleted":1,"killedBy":["56"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}},{"id":"252","mutatorName":"Regex","replacement":"/^(?:[A-Za-z]+|[0-9])$/","statusReason":"invalid command token: 001","status":"Killed","static":true,"testsCompleted":5,"killedBy":["60"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}},{"id":"253","mutatorName":"Regex","replacement":"/^(?:[A-Za-z]+|[^0-9]{3})$/","statusReason":"invalid command token: 001","status":"Killed","static":true,"testsCompleted":5,"killedBy":["60"],"coveredBy":[],"location":{"end":{"column":46,"line":100},"start":{"column":20,"line":100}}}],"source":"import type { IrcMessage, IrcSource } from './messages.js';\n\n/**\n * Error thrown when a wire line cannot be parsed as an IRC message.\n * The actor layer treats these as a protocol violation (and typically drops\n * or 421s the offending input) rather than crashing.\n */\nexport class IrcParseError extends Error {\n constructor(\n message: string,\n /** The original input that failed to parse, for diagnostics. */\n readonly input: string,\n ) {\n super(message);\n this.name = 'IrcParseError';\n }\n}\n\n/**\n * RFC 1459 §2.3: maximum IRC message length in bytes. The spec counts the\n * trailing CR-LF as part of the 512 budget; the parser strips CR-LF first,\n * so this constant caps the post-strip body. Adopted verbatim by the\n * output side (`outbound.ts` `MAX_LINE_BYTES`) — the same number everywhere\n * so the wire contract reads \"no IRC message exceeds 512 bytes\".\n *\n * Applied to inputs as a hard cap: a single line longer than this is a\n * protocol violation (the client is misbehaving or a downstream framer is\n * broken) and is rejected with `IrcParseError` rather than parsed.\n */\nexport const MAX_INPUT_BYTES = 512;\n\nconst TAG_ESCAPES: Readonly<"+"Record<"+"string, string>> = {\n ':': ';',\n s: ' ',\n '\\\\': '\\\\',\n r: '\\r',\n n: '\\n',\n};\n\n/**\n * Reverses {@link escapeTagValue} per the IRCv3 Message Tags spec. A `\\`\n * followed by one of the escape codes (`:`, `s`, `\\`, `r`, `n`) yields the\n * corresponding raw character; `\\` followed by any other character drops\n * the backslash (per spec: unknown escapes resolve to the literal char).\n */\nexport function unescapeTagValue(value: string): string {\n let out = '';\n for (let i = 0; i <"+" value.length; i++) {\n const ch = value.charAt(i);\n if (ch === '\\\\' && i + 1 <"+" value.length) {\n const next = value.charAt(i + 1);\n out += TAG_ESCAPES[next] ?? next;\n i++;\n } else {\n out += ch;\n }\n }\n return out;\n}\n\nfunction parseTags(section: string): Record<"+"string, string> {\n const tags: Record<"+"string, string> = {};\n for (const entry of section.split(';')) {\n if (entry === '') continue;\n const eq = entry.indexOf('=');\n if (eq === -1) {\n tags[entry] = '';\n } else {\n tags[entry.slice(0, eq)] = unescapeTagValue(entry.slice(eq + 1));\n }\n }\n return tags;\n}\n\nfunction parseSource(raw: string): IrcSource {\n // Grammar: servername | nick ['!' user] ['@' host]\n // '!' and '@' are independent, so split host (trailing) first, then user.\n let beforeHost = raw;\n let host: string | undefined;\n const at = raw.lastIndexOf('@');\n if (at !== -1) {\n host = raw.slice(at + 1);\n beforeHost = raw.slice(0, at);\n }\n\n let name = beforeHost;\n let user: string | undefined;\n const bang = beforeHost.indexOf('!');\n if (bang !== -1) {\n name = beforeHost.slice(0, bang);\n user = beforeHost.slice(bang + 1);\n }\n\n const source: IrcSource = { name };\n if (user !== undefined) source.user = user;\n if (host !== undefined) source.host = host;\n return source;\n}\n\nconst COMMAND_RE = /^(?:[A-Za-z]+|[0-9]{3})$/;\n\n/**\n * Parses one IRC message from a single line (with or without a trailing\n * CR/LF). Throws {@link IrcParseError} on malformed input.\n *\n * Lenient where real-world clients are: tolerates missing CR/LF and collapses\n * accidental runs of spaces between tokens. Strict where the grammar is: a\n * command token is required and must be all-letters or exactly three digits.\n */\nexport function parse(line: string): IrcMessage {\n // Strip any trailing CR/LF (frame tolerance for ws text frames or TCP lines).\n const stripped = line.replace(/[\\r\\n]+$/u, '');\n if (stripped.length === 0) {\n throw new IrcParseError('empty message', line);\n }\n\n // RFC 1459 §2.3 hard cap: reject any single message whose post-CRLF body\n // exceeds {@link MAX_INPUT_BYTES} bytes. Prevents memory and processing\n // abuse from over-long inputs and matches the symmetric cap enforced on\n // outbound lines by `outbound.ts`.\n if (stripped.length > MAX_INPUT_BYTES) {\n throw new IrcParseError(\n `message exceeds ${MAX_INPUT_BYTES}-byte input cap (${stripped.length} bytes)`,\n line,\n );\n }\n\n let rest = stripped;\n\n let tags: Record<"+"string, string> = {};\n if (rest.startsWith('@')) {\n const space = rest.indexOf(' ');\n if (space === -1) {\n throw new IrcParseError('tag section with no message body', line);\n }\n tags = parseTags(rest.slice(1, space));\n rest = rest.slice(space + 1).trimStart();\n }\n\n let source: IrcSource | undefined;\n if (rest.startsWith(':')) {\n const space = rest.indexOf(' ');\n if (space === -1) {\n throw new IrcParseError('source prefix with no command', line);\n }\n source = parseSource(rest.slice(1, space));\n rest = rest.slice(space + 1).trimStart();\n }\n\n if (rest.length === 0) {\n throw new IrcParseError('missing command', line);\n }\n\n // Split command from the parameter remainder.\n const firstSpace = rest.indexOf(' ');\n let command: string;\n let remainder: string;\n if (firstSpace === -1) {\n command = rest;\n remainder = '';\n } else {\n command = rest.slice(0, firstSpace);\n remainder = rest.slice(firstSpace + 1);\n }\n\n command = command.toUpperCase();\n if (!COMMAND_RE.test(command)) {\n throw new IrcParseError(`invalid command token: ${command}`, line);\n }\n\n const params = parseParams(remainder);\n\n const message: IrcMessage = { tags, command, params };\n if (source !== undefined) message.source = source;\n return message;\n}\n\nfunction parseParams(remainder: string): string[] {\n if (remainder.length === 0) return [];\n\n let middle: string;\n let trailing: string | undefined;\n\n if (remainder.startsWith(':')) {\n // The whole remainder is a trailing param.\n middle = '';\n trailing = remainder.slice(1);\n } else {\n const trailingMarker = remainder.indexOf(' :');\n if (trailingMarker === -1) {\n middle = remainder;\n } else {\n middle = remainder.slice(0, trailingMarker);\n trailing = remainder.slice(trailingMarker + 2);\n }\n }\n\n const middles = middle.length === 0 ? [] : middle.split(' ').filter((p) => p.length > 0);\n return trailing === undefined ? middles : [...middles, trailing];\n}\n"},"src/protocol/serializer.ts":{"language":"typescript","mutants":[{"id":"366","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected '@time=undefined;account=undefined PRI…' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":2,"line":21},"start":{"column":55,"line":14}}},{"id":"367","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@a=ab PING' to be '@a=a\\\\b PING' // Object.is equality","status":"Killed","static":false,"testsCompleted":4,"killedBy":["217"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":27,"line":16},"start":{"column":21,"line":16}}},{"id":"368","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@a=ab PING' to be '@a=a\\:b PING' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["215"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":25,"line":17},"start":{"column":20,"line":17}}},{"id":"369","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@a=ab PING' to be '@a=a\\sb PING' // Object.is equality","status":"Killed","static":false,"testsCompleted":3,"killedBy":["216"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":25,"line":18},"start":{"column":20,"line":18}}},{"id":"370","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '' to be '\\r' // Object.is equality","status":"Killed","static":false,"testsCompleted":12,"killedBy":["167"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":26,"line":19},"start":{"column":21,"line":19}}},{"id":"371","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '' to be '\\n' // Object.is equality","status":"Killed","static":false,"testsCompleted":13,"killedBy":["168"],"coveredBy":["164","165","166","167","168","169","175","176","177","203","213","215","216","217","218","220","221","1001","1003","1006","1013","1014","1017","1018","1019","1020","1021","1023","1024","1025","1026","1027","1028","1029","1030","1031","1032","1034","1035","1040","1041","1044","1046","1047"],"location":{"end":{"column":26,"line":20},"start":{"column":21,"line":20}}},{"id":"372","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected ':undefined PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":2,"line":28},"start":{"column":50,"line":23}}},{"id":"373","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected ':irc.example.com!undefined NOTICE * :…' to be ':irc.example.com NOTICE * :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["207"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":25},"start":{"column":7,"line":25}}},{"id":"374","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':nick@host PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":25},"start":{"column":7,"line":25}}},{"id":"375","mutatorName":"EqualityOperator","replacement":"source.user === undefined","statusReason":"expected ':nick@host PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":25},"start":{"column":7,"line":25}}},{"id":"376","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected ':@host PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","218","220","221"],"location":{"end":{"column":63,"line":25},"start":{"column":40,"line":25}}},{"id":"377","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected ':irc.example.com@undefined NOTICE * :…' to be ':irc.example.com NOTICE * :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["207"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":26},"start":{"column":7,"line":26}}},{"id":"378","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected ':nick!user PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":26},"start":{"column":7,"line":26}}},{"id":"379","mutatorName":"EqualityOperator","replacement":"source.host === undefined","statusReason":"expected ':nick!user PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":32,"line":26},"start":{"column":7,"line":26}}},{"id":"380","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected ': PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","218","220","221"],"location":{"end":{"column":63,"line":26},"start":{"column":40,"line":26}}},{"id":"381","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":2,"line":66},"start":{"column":56,"line":38}}},{"id":"382","mutatorName":"ArrayDeclaration","replacement":"[\"Stryker was here\"]","statusReason":"expected undefined to be 'hi ; there \\ done' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["177"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":28,"line":39},"start":{"column":26,"line":39}}},{"id":"384","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected 'PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":9,"killedBy":["213"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":28,"line":42},"start":{"column":7,"line":42}}},{"id":"383","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@ PRIVMSG #foo :hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":28,"line":42},"start":{"column":7,"line":42}}},{"id":"385","mutatorName":"EqualityOperator","replacement":"tagEntries.length >= 0","statusReason":"expected '@ PRIVMSG #foo :hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":28,"line":42},"start":{"column":7,"line":42}}},{"id":"386","mutatorName":"EqualityOperator","replacement":"tagEntries.length <"+"= 0","statusReason":"expected '@ PRIVMSG #foo :hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":28,"line":42},"start":{"column":7,"line":42}}},{"id":"387","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected 'PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":4,"line":47},"start":{"column":30,"line":42}}},{"id":"388","mutatorName":"ArrowFunction","replacement":"() => undefined","statusReason":"expected '@; PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":86,"line":44},"start":{"column":12,"line":44}}},{"id":"389","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected '@time;account PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":43,"line":44},"start":{"column":31,"line":44}}},{"id":"390","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected '@+away= PRIVMSG #foo :hi' to be '@+away PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["214"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":43,"line":44},"start":{"column":31,"line":44}}},{"id":"391","mutatorName":"EqualityOperator","replacement":"value !== ''","statusReason":"expected '@time;account PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":43,"line":44},"start":{"column":31,"line":44}}},{"id":"392","mutatorName":"StringLiteral","replacement":"\"Stryker was here!\"","statusReason":"expected '@+away= PRIVMSG #foo :hi' to be '@+away PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["214"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":43,"line":44},"start":{"column":41,"line":44}}},{"id":"393","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '@; PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","215","216","217","218","220","221"],"location":{"end":{"column":85,"line":44},"start":{"column":52,"line":44}}},{"id":"394","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@time=taccount=a PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":16,"line":45},"start":{"column":13,"line":45}}},{"id":"395","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected ' PRIVMSG #foo :hi' to be '@time=t;account=a PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["213"],"coveredBy":["177","203","213","214","215","216","217","218","220","221"],"location":{"end":{"column":31,"line":46},"start":{"column":15,"line":46}}},{"id":"396","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"Cannot read properties of undefined (reading 'name')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":35,"line":49},"start":{"column":7,"line":49}}},{"id":"397","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected 'PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["206"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":35,"line":49},"start":{"column":7,"line":49}}},{"id":"398","mutatorName":"EqualityOperator","replacement":"message.source === undefined","statusReason":"Cannot read properties of undefined (reading 'name')","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":35,"line":49},"start":{"column":7,"line":49}}},{"id":"399","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected 'PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":4,"line":51},"start":{"column":37,"line":49}}},{"id":"400","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected ' PRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["206"],"coveredBy":["206","207","218","220","221"],"location":{"end":{"column":49,"line":50},"start":{"column":15,"line":50}}},{"id":"401","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected 'PRIVMSG' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":26,"line":56},"start":{"column":7,"line":56}}},{"id":"403","mutatorName":"EqualityOperator","replacement":"params.length !== 0","statusReason":"expected 'PRIVMSG' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["205"],"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":26,"line":56},"start":{"column":7,"line":56}}},{"id":"402","mutatorName":"ConditionalExpression","replacement":"false","status":"Survived","static":false,"testsCompleted":19,"coveredBy":["177","203","205","206","207","208","209","210","211","212","213","214","215","216","217","218","219","220","221"],"location":{"end":{"column":26,"line":56},"start":{"column":7,"line":56}}},{"id":"405","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected '@a=a\\:bPING' to be '@a=a\\:b PING' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["215"],"coveredBy":["177","211","215","216","217","220","221"],"location":{"end":{"column":25,"line":57},"start":{"column":22,"line":57}}},{"id":"404","mutatorName":"BlockStatement","replacement":"{}","status":"Survived","static":false,"testsCompleted":7,"coveredBy":["177","211","215","216","217","220","221"],"location":{"end":{"column":4,"line":58},"start":{"column":28,"line":56}}},{"id":"406","mutatorName":"ArithmeticOperator","replacement":"params.length + 1","statusReason":"expected 'PRIVMSG #foo hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":38,"line":60},"start":{"column":21,"line":60}}},{"id":"407","mutatorName":"StringLiteral","replacement":"\"\"","statusReason":"expected ':nick!user@hostPRIVMSG #foo :hi' to be ':nick!user@host PRIVMSG #foo :hi' // Object.is equality","status":"Killed","static":false,"testsCompleted":2,"killedBy":["206"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":26,"line":61},"start":{"column":23,"line":61}}},{"id":"408","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected 'PRIVMSG' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":4,"line":64},"start":{"column":46,"line":62}}},{"id":"409","mutatorName":"AssignmentOperator","replacement":"out -= i === lastIndex ? ` :${param}` : ` ${param}`","statusReason":"expected NaN to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":56,"line":63},"start":{"column":5,"line":63}}},{"id":"410","mutatorName":"ConditionalExpression","replacement":"true","statusReason":"expected 'PRIVMSG :#foo :hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":27,"line":63},"start":{"column":12,"line":63}}},{"id":"411","mutatorName":"ConditionalExpression","replacement":"false","statusReason":"expected 'PRIVMSG #foo hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":27,"line":63},"start":{"column":12,"line":63}}},{"id":"412","mutatorName":"EqualityOperator","replacement":"i !== lastIndex","statusReason":"expected 'PRIVMSG :#foo hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":27,"line":63},"start":{"column":12,"line":63}}},{"id":"413","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected 'PRIVMSG #foo' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","210","212","213","214","218","219","220","221"],"location":{"end":{"column":42,"line":63},"start":{"column":30,"line":63}}},{"id":"414","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected 'PRIVMSG :hello world' to be 'PRIVMSG #foo :hello world' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["205"],"coveredBy":["203","205","206","207","208","209","212","213","214","218","220","221"],"location":{"end":{"column":56,"line":63},"start":{"column":45,"line":63}}},{"id":"415","mutatorName":"BlockStatement","replacement":"{}","statusReason":"expected undefined to be 'PING :tok\\r\\n' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["219"],"coveredBy":["219"],"location":{"end":{"column":2,"line":71},"start":{"column":61,"line":69}}},{"id":"416","mutatorName":"StringLiteral","replacement":"``","statusReason":"expected '' to be 'PING :tok\\r\\n' // Object.is equality","status":"Killed","static":false,"testsCompleted":1,"killedBy":["219"],"coveredBy":["219"],"location":{"end":{"column":37,"line":70},"start":{"column":10,"line":70}}}],"source":"import type { IrcMessage, IrcSource } from './messages.js';\n\n/**\n * Escapes a raw tag value per the IRCv3 Message Tags spec so it can appear\n * inside an `@...` tag section without containing literal `;`, space, `\\`,\n * CR or LF (all of which are structural or forbidden in the wire format).\n *\n * Escape table (the reverse of {@link unescapeTagValue}):\n * `;` → `\\:` ` ` → `\\s` `\\` → `\\\\` CR → `\\r` LF → `\\n`\n *\n * Because `\\` is doubled first, the mapping is a bijection and\n * `unescapeTagValue(escapeTagValue(s)) === s` for every string.\n */\nexport function escapeTagValue(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/;/g, '\\\\:')\n .replace(/ /g, '\\\\s')\n .replace(/\\r/g, '\\\\r')\n .replace(/\\n/g, '\\\\n');\n}\n\nfunction formatSource(source: IrcSource): string {\n let out = source.name;\n if (source.user !== undefined) out = `${out}!${source.user}`;\n if (source.host !== undefined) out = `${out}@${source.host}`;\n return out;\n}\n\n/**\n * Serializes a message to its canonical wire form WITHOUT a trailing CR/LF.\n * Use {@link serializeFrame} when the transport expects CRLF framing.\n *\n * The last parameter is ALWAYS emitted in trailing (`:`) form. This matches\n * the de-facto IRC server convention (replies and message bodies always carry\n * the colon) and parses correctly on every conformant client.\n */\nexport function serialize(message: IrcMessage): string {\n const lead: string[] = [];\n\n const tagEntries = Object.entries(message.tags);\n if (tagEntries.length > 0) {\n const tagSection = tagEntries\n .map(([key, value]) => (value === '' ? key : `${key}=${escapeTagValue(value)}`))\n .join(';');\n lead.push(`@${tagSection}`);\n }\n\n if (message.source !== undefined) {\n lead.push(`:${formatSource(message.source)}`);\n }\n\n lead.push(message.command);\n\n const { params } = message;\n if (params.length === 0) {\n return lead.join(' ');\n }\n\n const lastIndex = params.length - 1;\n let out = lead.join(' ');\n for (const [i, param] of params.entries()) {\n out += i === lastIndex ? ` :${param}` : ` ${param}`;\n }\n return out;\n}\n\n/** Serializes a message and appends the canonical IRC CR/LF frame terminator. */\nexport function serializeFrame(message: IrcMessage): string {\n return `${serialize(message)}\\r\\n`;\n}\n"}},"schemaVersion":"1.0","thresholds":{"high":80,"low":60,"break":80},"testFiles":{"tests/numerics.test.ts":{"tests":[{"id":"0","name":"Numerics registry maps RPL_WELCOME to 1"},{"id":"1","name":"Numerics registry maps RPL_ISUPPORT to 5"},{"id":"2","name":"Numerics registry maps RPL_ENDOFMOTD to 376"},{"id":"3","name":"Numerics registry maps ERR_NOSUCHNICK to 401"},{"id":"4","name":"Numerics registry maps ERR_NEEDMOREPARAMS to 461"},{"id":"5","name":"Numerics registry maps RPL_WHOWASUSER to 314"},{"id":"6","name":"Numerics registry maps RPL_ENDOFWHOWAS to 369"},{"id":"7","name":"Numerics registry maps RPL_TRACELINK to 200"},{"id":"8","name":"Numerics registry maps RPL_TRACEOPERATOR to 204"},{"id":"9","name":"Numerics registry maps RPL_TRACEUSER to 205"},{"id":"10","name":"Numerics registry maps RPL_TRACESERVER to 206"},{"id":"11","name":"Numerics registry maps RPL_ENDOFTRACE to 262"},{"id":"12","name":"Numerics registry maps RPL_LUSERCLIENT to 251"},{"id":"13","name":"Numerics registry maps RPL_LUSEROP to 252"},{"id":"14","name":"Numerics registry maps RPL_LUSERUNKNOWN to 253"},{"id":"15","name":"Numerics registry maps RPL_LUSERCHANNELS to 254"},{"id":"16","name":"Numerics registry maps RPL_LUSERME to 255"},{"id":"17","name":"Numerics registry maps RPL_LOCALUSERS to 265"},{"id":"18","name":"Numerics registry maps RPL_GLOBALUSERS to 266"},{"id":"19","name":"Numerics registry maps RPL_STATSLINKINFO to 211"},{"id":"20","name":"Numerics registry maps RPL_ENDOFSTATS to 219"},{"id":"21","name":"Numerics registry maps RPL_STATSUPTIME to 242"},{"id":"22","name":"Numerics registry maps ERR_WASNOSUCHNICK to 406"},{"id":"23","name":"Numerics registry maps ERR_NICKNAMEINUSE to 433"},{"id":"24","name":"Numerics registry maps ERR_CHANOPRIVSNEEDED to 482"},{"id":"25","name":"Numerics registry keeps every code within the 3-digit range [1,999]"},{"id":"26","name":"Numerics registry uses unique codes across all names (no aliases collide)"},{"id":"27","name":"Numerics registry uses SCREAMING_SNAKE_CASE names only"},{"id":"28","name":"Numerics registry keeps NumericCode assignable to keys of Numerics"},{"id":"29","name":"numericToName reverses ERR_NICKNAMEINUSE"},{"id":"30","name":"numericToName reverses RPL_WELCOME"},{"id":"31","name":"numericToName reverses RPL_LUSERCLIENT"},{"id":"32","name":"numericToName reverses RPL_GLOBALUSERS"},{"id":"33","name":"numericToName reverses RPL_STATSLINKINFO"},{"id":"34","name":"numericToName reverses RPL_ENDOFSTATS"},{"id":"35","name":"numericToName reverses RPL_STATSUPTIME"},{"id":"36","name":"numericToName returns undefined for an unregistered code"},{"id":"37","name":"numericToName round-trips every entry in the Numerics registry (reverse-lookup parity)"},{"id":"38","name":"isNumericCommand treats 001 as a numeric command"},{"id":"39","name":"isNumericCommand treats 005 as a numeric command"},{"id":"40","name":"isNumericCommand treats 433 as a numeric command"},{"id":"41","name":"isNumericCommand treats 999 as a numeric command"},{"id":"42","name":"isNumericCommand treats \"PRIVMSG\" as a non-numeric command"},{"id":"43","name":"isNumericCommand treats \"privmsg\" as a non-numeric command"},{"id":"44","name":"isNumericCommand treats \"nick\" as a non-numeric command"},{"id":"45","name":"isNumericCommand treats \"\" as a non-numeric command"},{"id":"46","name":"isNumericCommand treats \"1\" as a non-numeric command"},{"id":"47","name":"isNumericCommand treats \"01\" as a non-numeric command"},{"id":"48","name":"isNumericCommand treats \"0001\" as a non-numeric command"},{"id":"49","name":"isNumericCommand treats \"1234\" as a non-numeric command"},{"id":"50","name":"isNumericCommand treats \"PING \" as a non-numeric command"},{"id":"51","name":"formatNumeric zero-pads a single digit to three places"},{"id":"52","name":"formatNumeric formats a three-digit code unchanged"},{"id":"53","name":"formatNumeric zero-pads RPL_ISUPPORT"},{"id":"54","name":"formatNumeric formats RPL_LUSERCLIENT as 251"},{"id":"55","name":"formatNumeric formats RPL_ENDOFSTATS as 219"}],"source":"import { describe, expect, it } from 'vitest';\nimport type { NumericCode } from '../src/protocol/numerics';\nimport { Numerics, formatNumeric, isNumericCommand, numericToName } from '../src/protocol/numerics';\n\ndescribe('Numerics registry', () => {\n it('maps RPL_WELCOME to 1', () => {\n expect(Numerics.RPL_WELCOME).toBe(1);\n });\n\n it('maps RPL_ISUPPORT to 5', () => {\n expect(Numerics.RPL_ISUPPORT).toBe(5);\n });\n\n it('maps RPL_ENDOFMOTD to 376', () => {\n expect(Numerics.RPL_ENDOFMOTD).toBe(376);\n });\n\n it('maps ERR_NOSUCHNICK to 401', () => {\n expect(Numerics.ERR_NOSUCHNICK).toBe(401);\n });\n\n it('maps ERR_NEEDMOREPARAMS to 461', () => {\n expect(Numerics.ERR_NEEDMOREPARAMS).toBe(461);\n });\n\n it('maps RPL_WHOWASUSER to 314', () => {\n expect(Numerics.RPL_WHOWASUSER).toBe(314);\n });\n\n it('maps RPL_ENDOFWHOWAS to 369', () => {\n expect(Numerics.RPL_ENDOFWHOWAS).toBe(369);\n });\n\n it('maps RPL_TRACELINK to 200', () => {\n expect(Numerics.RPL_TRACELINK).toBe(200);\n });\n\n it('maps RPL_TRACEOPERATOR to 204', () => {\n expect(Numerics.RPL_TRACEOPERATOR).toBe(204);\n });\n\n it('maps RPL_TRACEUSER to 205', () => {\n expect(Numerics.RPL_TRACEUSER).toBe(205);\n });\n\n it('maps RPL_TRACESERVER to 206', () => {\n expect(Numerics.RPL_TRACESERVER).toBe(206);\n });\n\n it('maps RPL_ENDOFTRACE to 262', () => {\n expect(Numerics.RPL_ENDOFTRACE).toBe(262);\n });\n\n // -- LUSERS (RFC 2812 §4.6.2) ----------------------------------------------\n it('maps RPL_LUSERCLIENT to 251', () => {\n expect(Numerics.RPL_LUSERCLIENT).toBe(251);\n });\n\n it('maps RPL_LUSEROP to 252', () => {\n expect(Numerics.RPL_LUSEROP).toBe(252);\n });\n\n it('maps RPL_LUSERUNKNOWN to 253', () => {\n expect(Numerics.RPL_LUSERUNKNOWN).toBe(253);\n });\n\n it('maps RPL_LUSERCHANNELS to 254', () => {\n expect(Numerics.RPL_LUSERCHANNELS).toBe(254);\n });\n\n it('maps RPL_LUSERME to 255', () => {\n expect(Numerics.RPL_LUSERME).toBe(255);\n });\n\n it('maps RPL_LOCALUSERS to 265', () => {\n expect(Numerics.RPL_LOCALUSERS).toBe(265);\n });\n\n it('maps RPL_GLOBALUSERS to 266', () => {\n expect(Numerics.RPL_GLOBALUSERS).toBe(266);\n });\n\n // -- STATS (RFC 2812 §4.6.3) -----------------------------------------------\n it('maps RPL_STATSLINKINFO to 211', () => {\n expect(Numerics.RPL_STATSLINKINFO).toBe(211);\n });\n\n it('maps RPL_ENDOFSTATS to 219', () => {\n expect(Numerics.RPL_ENDOFSTATS).toBe(219);\n });\n\n it('maps RPL_STATSUPTIME to 242', () => {\n expect(Numerics.RPL_STATSUPTIME).toBe(242);\n });\n\n it('maps ERR_WASNOSUCHNICK to 406', () => {\n expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);\n });\n\n it('maps ERR_NICKNAMEINUSE to 433', () => {\n expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);\n });\n\n it('maps ERR_CHANOPRIVSNEEDED to 482', () => {\n expect(Numerics.ERR_CHANOPRIVSNEEDED).toBe(482);\n });\n\n it('keeps every code within the 3-digit range [1,999]', () => {\n for (const code of Object.values(Numerics)) {\n expect(code).toBeGreaterThanOrEqual(1);\n expect(code).toBeLessThanOrEqual(999);\n expect(Number.isInteger(code)).toBe(true);\n }\n });\n\n it('uses unique codes across all names (no aliases collide)', () => {\n const codes = Object.values(Numerics);\n expect(new Set(codes).size).toBe(codes.length);\n });\n\n it('uses SCREAMING_SNAKE_CASE names only', () => {\n for (const name of Object.keys(Numerics)) {\n expect(name).toMatch(/^[A-Z][A-Z0-9_]*$/);\n }\n });\n\n it('keeps NumericCode assignable to keys of Numerics', () => {\n const name: NumericCode = 'ERR_NICKNAMEINUSE';\n expect(Numerics[name]).toBe(433);\n });\n});\n\ndescribe('numericToName', () => {\n it('reverses ERR_NICKNAMEINUSE', () => {\n expect(numericToName.get(433)).toBe('ERR_NICKNAMEINUSE');\n });\n\n it('reverses RPL_WELCOME', () => {\n expect(numericToName.get(1)).toBe('RPL_WELCOME');\n });\n\n it('reverses RPL_LUSERCLIENT', () => {\n expect(numericToName.get(251)).toBe('RPL_LUSERCLIENT');\n });\n\n it('reverses RPL_GLOBALUSERS', () => {\n expect(numericToName.get(266)).toBe('RPL_GLOBALUSERS');\n });\n\n it('reverses RPL_STATSLINKINFO', () => {\n expect(numericToName.get(211)).toBe('RPL_STATSLINKINFO');\n });\n\n it('reverses RPL_ENDOFSTATS', () => {\n expect(numericToName.get(219)).toBe('RPL_ENDOFSTATS');\n });\n\n it('reverses RPL_STATSUPTIME', () => {\n expect(numericToName.get(242)).toBe('RPL_STATSUPTIME');\n });\n\n it('returns undefined for an unregistered code', () => {\n expect(numericToName.get(600)).toBeUndefined();\n });\n\n it('round-trips every entry in the Numerics registry (reverse-lookup parity)', () => {\n // Pin the map-construction arrow function: every [name, code] pair in\n // the forward table MUST appear inverted in numericToName. A mutated\n // mapper (e.g. `() => undefined`) would leave the reverse table empty\n // or fail to construct, which this exhaustive check catches.\n const entries = Object.entries(Numerics) as ReadonlyArray<"+"[NumericCode, number]>;\n expect(entries.length).toBeGreaterThan(0);\n for (const [name, code] of entries) {\n expect(numericToName.get(code)).toBe(name);\n }\n expect(numericToName.size).toBe(entries.length);\n });\n});\n\ndescribe('isNumericCommand', () => {\n it.each(['001', '005', '433', '999'])('treats %s as a numeric command', (code) => {\n expect(isNumericCommand(code)).toBe(true);\n });\n\n it.each(['PRIVMSG', 'privmsg', 'nick', '', '1', '01', '0001', '1234', 'PING '])(\n 'treats %j as a non-numeric command',\n (code) => {\n expect(isNumericCommand(code)).toBe(false);\n },\n );\n});\n\ndescribe('formatNumeric', () => {\n it('zero-pads a single digit to three places', () => {\n expect(formatNumeric('RPL_WELCOME')).toBe('001');\n });\n\n it('formats a three-digit code unchanged', () => {\n expect(formatNumeric('ERR_NICKNAMEINUSE')).toBe('433');\n });\n\n it('zero-pads RPL_ISUPPORT', () => {\n expect(formatNumeric('RPL_ISUPPORT')).toBe('005');\n });\n\n it('formats RPL_LUSERCLIENT as 251', () => {\n expect(formatNumeric('RPL_LUSERCLIENT')).toBe('251');\n });\n\n it('formats RPL_ENDOFSTATS as 219', () => {\n expect(formatNumeric('RPL_ENDOFSTATS')).toBe('219');\n });\n});\n"},"tests/parser.test.ts":{"tests":[{"id":"56","name":"parse — golden vectors parses a simple command with a channel and trailing text"},{"id":"57","name":"parse — golden vectors parses a nick!user@host source"},{"id":"58","name":"parse — golden vectors parses PING with a trailing token"},{"id":"59","name":"parse — golden vectors parses a command with no parameters"},{"id":"60","name":"parse — golden vectors parses a numeric reply with a server source"},{"id":"61","name":"parse — golden vectors normalizes a lower-case command to upper-case"},{"id":"62","name":"parse — golden vectors parses multiple middle parameters"},{"id":"63","name":"parse — golden vectors parses an empty trailing parameter"},{"id":"64","name":"parse — golden vectors parses a server source without user or host"},{"id":"65","name":"parse — golden vectors keeps a colon mid-parameter non-trailing"},{"id":"66","name":"parse — golden vectors treats everything after the first space-colon as trailing (literal colons)"},{"id":"67","name":"parse — golden vectors parses up to 14 middle params plus a trailing param (15 total)"},{"id":"68","name":"parse — message-tags (IRCv3) parses two valued tags"},{"id":"69","name":"parse — message-tags (IRCv3) parses a vendor-prefixed tag"},{"id":"70","name":"parse — message-tags (IRCv3) parses a valueless tag as the empty string"},{"id":"71","name":"parse — message-tags (IRCv3) decodes the \":\" escape to a semicolon"},{"id":"72","name":"parse — message-tags (IRCv3) decodes the \"s\" escape to a space"},{"id":"73","name":"parse — message-tags (IRCv3) decodes the \"\\\\\" escape to a single backslash"},{"id":"74","name":"parse — message-tags (IRCv3) keeps an unknown escape sequence as the literal character"},{"id":"75","name":"parse — message-tags (IRCv3) skips empty tag entries produced by doubled semicolons"},{"id":"76","name":"parse — message-tags (IRCv3) parses tags together with a source"},{"id":"77","name":"parse — framing tolerance strips a trailing CR LF"},{"id":"78","name":"parse — framing tolerance strips a lone trailing LF"},{"id":"79","name":"parse — framing tolerance preserves CR/LF that appears inside the trailing param"},{"id":"80","name":"parse — malformed input throws IrcParseError on empty input"},{"id":"81","name":"parse — malformed input throws IrcParseError on whitespace-only input"},{"id":"82","name":"parse — malformed input throws IrcParseError when only a source prefix is present"},{"id":"83","name":"parse — malformed input throws IrcParseError when a source is followed by no command"},{"id":"84","name":"parse — malformed input throws IrcParseError when a tag section has no body"},{"id":"85","name":"parse — malformed input throws IrcParseError on an invalid command token"},{"id":"86","name":"parse — malformed input sets the error `name` to \"IrcParseError\" for instanceof-equivalent checks"},{"id":"87","name":"parse — RFC 1459 §2.3 input line-length cap accepts a line whose body is exactly 512 bytes (post-CRLF-strip)"},{"id":"88","name":"parse — RFC 1459 §2.3 input line-length cap accepts a line whose body is exactly 512 bytes including a trailing CR-LF"},{"id":"89","name":"parse — RFC 1459 §2.3 input line-length cap throws IrcParseError on a line whose body exceeds 512 bytes (post-strip)"},{"id":"90","name":"parse — RFC 1459 §2.3 input line-length cap throws IrcParseError on an over-long line even with tags stripped"},{"id":"91","name":"parse — RFC 1459 §2.3 input line-length cap reports the actual byte cap in the error message for diagnostics"},{"id":"92","name":"parse — command token grammar (anchored regex) rejects a command token with trailing digits (end anchor)"},{"id":"93","name":"parse — command token grammar (anchored regex) rejects a command token with leading digits (start anchor)"},{"id":"94","name":"parse — command token grammar (anchored regex) rejects a two-digit numeric command (length pin)"},{"id":"95","name":"parse — source prefix shape (strict property presence) omits user AND host from a bare-servername source"},{"id":"96","name":"parse — source prefix shape (strict property presence) omits host from a nick!user source (no @host)"},{"id":"97","name":"parse — source prefix shape (strict property presence) omits user from a nick@host source (no !user)"},{"id":"98","name":"parse — source prefix shape (strict property presence) does not set a source property on a source-less message"},{"id":"99","name":"parse — middle parameter whitespace collapsing drops empty params produced by runs of spaces between middles"},{"id":"100","name":"unescapeTagValue — boundary conditions keeps a lone trailing backslash verbatim"},{"id":"101","name":"unescapeTagValue — boundary conditions handles a tag value that is only a backslash"},{"id":"102","name":"unescapeTagValue — boundary conditions decodes a trailing escape sequence with no following character partially"}],"source":"import { describe, expect, it } from 'vitest';\nimport type { IrcMessage } from '../src/protocol/messages';\nimport { IrcParseError, MAX_INPUT_BYTES, parse } from '../src/protocol/parser';\n\ndescribe('parse — golden vectors', () => {\n it('parses a simple command with a channel and trailing text', () => {\n expect(parse('PRIVMSG #foo :hello world')).toEqual<"+"IrcMessage>({\n tags: {},\n command: 'PRIVMSG',\n params: ['#foo', 'hello world'],\n });\n });\n\n it('parses a nick!user@host source', () => {\n expect(parse(':nick!user@host PRIVMSG #foo :hi')).toEqual<"+"IrcMessage>({\n tags: {},\n source: { name: 'nick', user: 'user', host: 'host' },\n command: 'PRIVMSG',\n params: ['#foo', 'hi'],\n });\n });\n\n it('parses PING with a trailing token', () => {\n expect(parse('PING :tok')).toEqual<"+"IrcMessage>({\n tags: {},\n command: 'PING',\n params: ['tok'],\n });\n });\n\n it('parses a command with no parameters', () => {\n expect(parse('PONG')).toEqual<"+"IrcMessage>({ tags: {}, command: 'PONG', params: [] });\n });\n\n it('parses a numeric reply with a server source', () => {\n expect(parse(':irc.example.com 001 nick :Welcome')).toEqual<"+"IrcMessage>({\n tags: {},\n source: { name: 'irc.example.com' },\n command: '001',\n params: ['nick', 'Welcome'],\n });\n });\n\n it('normalizes a lower-case command to upper-case', () => {\n expect(parse('privmsg #foo :hi').command).toBe('PRIVMSG');\n });\n\n it('parses multiple middle parameters', () => {\n expect(parse(':nick JOIN #a #b key').params).toEqual(['#a', '#b', 'key']);\n });\n\n it('parses an empty trailing parameter', () => {\n expect(parse(':nick TOPIC #foo :')).toEqual<"+"IrcMessage>({\n tags: {},\n source: { name: 'nick' },\n command: 'TOPIC',\n params: ['#foo', ''],\n });\n });\n\n it('parses a server source without user or host', () => {\n expect(parse(':irc.example.com NOTICE * :hi').source).toEqual({ name: 'irc.example.com' });\n });\n\n it('keeps a colon mid-parameter non-trailing', () => {\n expect(parse('PRIVMSG #foo a:b c').params).toEqual(['#foo', 'a:b', 'c']);\n });\n\n it('treats everything after the first space-colon as trailing (literal colons)', () => {\n expect(parse('TOPIC #foo :topic with :colons').params).toEqual(['#foo', 'topic with :colons']);\n });\n\n it('parses up to 14 middle params plus a trailing param (15 total)', () => {\n const middles = Array.from({ length: 14 }, (_, i) => `p${i}`);\n const line = `PRIVMSG ${middles.join(' ')} :trailing`;\n const parsed = parse(line);\n expect(parsed.params).toEqual([...middles, 'trailing']);\n expect(parsed.params).toHaveLength(15);\n });\n});\n\ndescribe('parse — message-tags (IRCv3)', () => {\n it('parses two valued tags', () => {\n expect(parse('@time=2024-01-01T00:00:00Z;account=foo PRIVMSG #foo :hi').tags).toEqual({\n time: '2024-01-01T00:00:00Z',\n account: 'foo',\n });\n });\n\n it('parses a vendor-prefixed tag', () => {\n expect(parse('@+draft/typing=1 PRIVMSG #foo :hi').tags).toEqual({ '+draft/typing': '1' });\n });\n\n it('parses a valueless tag as the empty string', () => {\n expect(parse('@+away PRIVMSG #foo :hi').tags).toEqual({ '+away': '' });\n });\n\n it('decodes the \":\" escape to a semicolon', () => {\n expect(parse('@a=foo\\\\:bar PRIVMSG #foo :hi').tags).toEqual({ a: 'foo;bar' });\n });\n\n it('decodes the \"s\" escape to a space', () => {\n expect(parse('@a=foo\\\\sbar PRIVMSG #foo :hi').tags).toEqual({ a: 'foo bar' });\n });\n\n it('decodes the \"\\\\\\\\\" escape to a single backslash', () => {\n expect(parse('@a=a\\\\\\\\b PRIVMSG #foo :hi').tags).toEqual({ a: 'a\\\\b' });\n });\n\n it('keeps an unknown escape sequence as the literal character', () => {\n expect(parse('@a=x\\\\qy PRIVMSG #foo :hi').tags).toEqual({ a: 'xqy' });\n });\n\n it('skips empty tag entries produced by doubled semicolons', () => {\n expect(parse('@a=b;;c=d PRIVMSG #foo :hi').tags).toEqual({ a: 'b', c: 'd' });\n });\n\n it('parses tags together with a source', () => {\n expect(parse('@account=foo :nick!u@h PRIVMSG #foo :hi')).toEqual<"+"IrcMessage>({\n tags: { account: 'foo' },\n source: { name: 'nick', user: 'u', host: 'h' },\n command: 'PRIVMSG',\n params: ['#foo', 'hi'],\n });\n });\n});\n\ndescribe('parse — framing tolerance', () => {\n it('strips a trailing CR LF', () => {\n expect(parse('PING :tok\\r\\n').params).toEqual(['tok']);\n });\n\n it('strips a lone trailing LF', () => {\n expect(parse('PING :tok\\n').params).toEqual(['tok']);\n });\n\n it('preserves CR/LF that appears inside the trailing param', () => {\n // The CR/LF strip regex is anchored to the END of the line only; a CR/LF\n // embedded in a trailing param must survive into the parsed value.\n expect(parse('PRIVMSG #foo :a\\rb').params[1]).toBe('a\\rb');\n expect(parse('PRIVMSG #foo :a\\nb').params[1]).toBe('a\\nb');\n });\n});\n\ndescribe('parse — malformed input', () => {\n // Each case asserts both the error class AND the diagnostic message text.\n // The message assertion is what pins the failure to the *correct* guard:\n // several guards fall through to a sibling throw of the same class, so a\n // bare `toThrow(IrcParseError)` would miss removed/disabled guards.\n\n it('throws IrcParseError on empty input', () => {\n expect(() => parse('')).toThrow(IrcParseError);\n expect(() => parse('')).toThrow('empty message');\n });\n\n it('throws IrcParseError on whitespace-only input', () => {\n expect(() => parse(' ')).toThrow(IrcParseError);\n expect(() => parse(' ')).toThrow('invalid command token');\n });\n\n it('throws IrcParseError when only a source prefix is present', () => {\n expect(() => parse(':nick!user@host')).toThrow(IrcParseError);\n expect(() => parse(':nick!user@host')).toThrow('source prefix with no command');\n });\n\n it('throws IrcParseError when a source is followed by no command', () => {\n expect(() => parse(':irc.example.com ')).toThrow(IrcParseError);\n expect(() => parse(':irc.example.com ')).toThrow('missing command');\n });\n\n it('throws IrcParseError when a tag section has no body', () => {\n expect(() => parse('@time=now')).toThrow(IrcParseError);\n expect(() => parse('@time=now')).toThrow('tag section with no message body');\n });\n\n it('throws IrcParseError on an invalid command token', () => {\n expect(() => parse('12 hi')).toThrow(IrcParseError);\n expect(() => parse('12 hi')).toThrow('invalid command token');\n });\n\n it('sets the error `name` to \"IrcParseError\" for instanceof-equivalent checks', () => {\n try {\n parse('');\n expect.fail('expected parse to throw');\n } catch (err) {\n expect((err as Error).name).toBe('IrcParseError');\n }\n });\n});\n\ndescribe('parse — RFC 1459 §2.3 input line-length cap', () => {\n it('accepts a line whose body is exactly 512 bytes (post-CRLF-strip)', () => {\n // 512 = \"PRIVMSG #foo :\" (14) + 498 'x' chars = 512-byte body.\n const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length);\n const line = `PRIVMSG #foo :${body}`;\n expect(line.length).toBe(MAX_INPUT_BYTES);\n const parsed = parse(line);\n expect(parsed.params).toEqual(['#foo', body]);\n });\n\n it('accepts a line whose body is exactly 512 bytes including a trailing CR-LF', () => {\n const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length);\n const parsed = parse(`PRIVMSG #foo :${body}\\r\\n`);\n expect(parsed.params[1]).toBe(body);\n });\n\n it('throws IrcParseError on a line whose body exceeds 512 bytes (post-strip)', () => {\n const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length + 1);\n const line = `PRIVMSG #foo :${body}`;\n expect(line.length).toBe(MAX_INPUT_BYTES + 1);\n expect(() => parse(line)).toThrow(IrcParseError);\n });\n\n it('throws IrcParseError on an over-long line even with tags stripped', () => {\n const tag = '@time=2024-01-01T00:00:00.000Z ';\n const body = 'x'.repeat(MAX_INPUT_BYTES - tag.length - 'PRIVMSG #foo :'.length + 1);\n const line = `${tag}PRIVMSG #foo :${body}`;\n expect(line.length).toBeGreaterThan(MAX_INPUT_BYTES);\n expect(() => parse(line)).toThrow(IrcParseError);\n });\n\n it('reports the actual byte cap in the error message for diagnostics', () => {\n const body = 'x'.repeat(MAX_INPUT_BYTES);\n try {\n parse(`PRIVMSG #foo :${body}`);\n expect.fail('expected parse to throw');\n } catch (err) {\n expect(err).toBeInstanceOf(IrcParseError);\n expect((err as Error).message).toMatch(/512/u);\n }\n });\n});\n\ndescribe('parse — command token grammar (anchored regex)', () => {\n // COMMAND_RE is anchored at both ends (^...$). A command token must be\n // EITHER all letters OR exactly three digits — mixed alphanumerics are a\n // protocol violation. These tests pin both anchors: removing ^ or $\n // would let the partial-match mutations through.\n\n it('rejects a command token with trailing digits (end anchor)', () => {\n expect(() => parse('PRIVMSG123 #foo :hi')).toThrow(IrcParseError);\n });\n\n it('rejects a command token with leading digits (start anchor)', () => {\n expect(() => parse('123PRIVMSG #foo :hi')).toThrow(IrcParseError);\n });\n\n it('rejects a two-digit numeric command (length pin)', () => {\n expect(() => parse('12 #foo')).toThrow(IrcParseError);\n });\n});\n\ndescribe('parse — source prefix shape (strict property presence)', () => {\n // `user` and `host` must be ABSENT (not merely `undefined`) when the\n // source has no `!user` / `@host` component. `toEqual` ignores\n // undefined-valued properties, so these mutants would survive a normal\n // equality check; `toStrictEqual` does not.\n\n it('omits user AND host from a bare-servername source', () => {\n expect(parse(':irc.example.com NOTICE * :hi').source).toStrictEqual({\n name: 'irc.example.com',\n });\n });\n\n it('omits host from a nick!user source (no @host)', () => {\n expect(parse(':nick!user PRIVMSG #foo :hi').source).toStrictEqual({\n name: 'nick',\n user: 'user',\n });\n });\n\n it('omits user from a nick@host source (no !user)', () => {\n expect(parse(':nick@host PRIVMSG #foo :hi').source).toStrictEqual({\n name: 'nick',\n host: 'host',\n });\n });\n\n it('does not set a source property on a source-less message', () => {\n const msg = parse('PRIVMSG #foo :hi');\n expect('source' in msg).toBe(false);\n });\n});\n\ndescribe('parse — middle parameter whitespace collapsing', () => {\n it('drops empty params produced by runs of spaces between middles', () => {\n // The split-then-filter pipeline removes the empty strings that\n // consecutive spaces generate. Removing the filter (or mutating the\n // predicate to a tautology) would leak '' entries here.\n expect(parse(':nick JOIN #a #b').params).toEqual(['#a', '#b']);\n expect(parse(':nick JOIN #a #b ').params).toEqual(['#a', '#b']);\n });\n});\n\ndescribe('unescapeTagValue — boundary conditions', () => {\n // Imported indirectly through parse() tag values; these pin the loop\n // bounds so off-by-one mutations on the backslash lookahead can't survive.\n\n it('keeps a lone trailing backslash verbatim', () => {\n // `\\` at the end with no following char must not read past the end of\n // the string and must be emitted literally.\n expect(parse('@a=foo\\\\ PRIVMSG #foo :hi').tags.a).toBe('foo\\\\');\n expect(parse('@a=\\\\ PRIVMSG #foo :hi').tags.a).toBe('\\\\');\n });\n\n it('handles a tag value that is only a backslash', () => {\n expect(parse('@a=\\\\ PRIVMSG #foo :hi').tags.a).toBe('\\\\');\n });\n\n it('decodes a trailing escape sequence with no following character partially', () => {\n // A complete `\\\\` pair decodes to a single backslash.\n expect(parse('@a=x\\\\\\\\ PRIVMSG #foo :hi').tags.a).toBe('x\\\\');\n });\n});\n"},"tests/commands/batch.test.ts":{"tests":[{"id":"103","name":"namesReducer — IRCv3 batch wraps the 353 + 366 reply in a BATCH frame when the requester has the batch cap"},{"id":"104","name":"namesReducer — IRCv3 batch leaves the reply unwrapped when the requester lacks the batch cap"},{"id":"105","name":"namesReducer — IRCv3 batch does NOT wrap the 403 error path in a batch"},{"id":"106","name":"joinReducer — IRCv3 batch wraps the joiner-only 353 + 366 reply in a BATCH when the joiner has the batch cap"},{"id":"107","name":"joinReducer — IRCv3 batch leaves the joiner NAMES reply unwrapped without the batch cap"},{"id":"108","name":"joinReducer — IRCv3 batch still wraps the 353+366 in a batch even when the broadcast is cap-split"}],"source":"import { describe, expect, it } from 'vitest';\nimport { joinReducer } from '../../src/commands/join';\nimport { namesReducer } from '../../src/commands/names';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, ids = new SequentialIdFactory()): Ctx {\n return buildCtx({\n serverConfig,\n clock: new FakeClock(1_000),\n ids,\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(chan: ChannelState, connId: string, nick: string): void {\n chan.members.set(connId, { conn: connId, nick, op: false, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// namesReducer — IRCv3 batch wrapping\n// ============================================================================\n\ndescribe('namesReducer — IRCv3 batch', () => {\n it('wraps the 353 + 366 reply in a BATCH frame when the requester has the batch cap', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('batch');\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L('BATCH +batch-0 names'),\n L(':irc.example.com 353 alice = #foo :alice'),\n L(':irc.example.com 366 alice #foo :End of /NAMES list.'),\n L('BATCH -batch-0'),\n ]),\n ]);\n });\n\n it('leaves the reply unwrapped when the requester lacks the batch cap', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 353 alice = #foo :alice'),\n L(':irc.example.com 366 alice #foo :End of /NAMES list.'),\n ]),\n ]);\n });\n\n it('does NOT wrap the 403 error path in a batch', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('batch');\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['badname'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice badname :No such channel')]),\n ]);\n });\n});\n\n// ============================================================================\n// joinReducer — IRCv3 batch wrapping of the joiner's NAMES reply\n// ============================================================================\n\ndescribe('joinReducer — IRCv3 batch', () => {\n it('wraps the joiner-only 353 + 366 reply in a BATCH when the joiner has the batch cap', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n conn.caps.add('batch');\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // The Send effect to the joiner (c1) carries the wrapped NAMES reply.\n // The Broadcast JOIN effect goes to the channel and is unaffected.\n const sendToJoiner = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> =>\n e.tag === 'Send' && e.to === 'c1' && e.lines.length === 4,\n );\n expect(sendToJoiner).toBeDefined();\n expect(sendToJoiner?.lines[0]?.text.startsWith('BATCH +batch-')).toBe(true);\n expect(sendToJoiner?.lines[0]?.text.endsWith(' join')).toBe(true);\n expect(sendToJoiner?.lines[1]?.text).toBe(':irc.example.com 353 alice = #foo :bob alice');\n expect(sendToJoiner?.lines[2]?.text).toBe(\n ':irc.example.com 366 alice #foo :End of /NAMES list.',\n );\n expect(sendToJoiner?.lines[3]?.text).toMatch(/^BATCH -batch-\\d+$/);\n });\n\n it('leaves the joiner NAMES reply unwrapped without the batch cap', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const sendToJoiner = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> =>\n e.tag === 'Send' && e.to === 'c1' && e.lines.length === 2,\n );\n expect(sendToJoiner).toBeDefined();\n expect(sendToJoiner?.lines[0]?.text).toBe(':irc.example.com 353 alice = #foo :bob alice');\n expect(sendToJoiner?.lines[1]?.text).toBe(\n ':irc.example.com 366 alice #foo :End of /NAMES list.',\n );\n });\n\n it('still wraps the 353+366 in a batch even when the broadcast is cap-split', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n conn.caps.add('batch');\n conn.caps.add('extended-join');\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // Broadcast (with extended-join cap-split) and Send-to-joiner (batched).\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.cap).toBe('extended-join');\n const send = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> =>\n e.tag === 'Send' && e.to === 'c1' && e.lines.length === 4,\n );\n expect(send?.lines[0]?.text.startsWith('BATCH +')).toBe(true);\n });\n});\n"},"tests/batch.test.ts":{"tests":[{"id":"109","name":"wrapBatch — framing wraps a non-empty line list with BATCH +id / -id markers"},{"id":"110","name":"wrapBatch — framing emits BATCH +id type :args when args are supplied"},{"id":"111","name":"wrapBatch — framing returns the input unchanged when the line list is empty"},{"id":"112","name":"wrapBatch — framing returns an empty BatchFrame carrying the framing metadata when lines is empty"},{"id":"113","name":"wrapBatch — framing preserves the order of the inner lines"},{"id":"114","name":"wrapBatch — framing produces a stable, round-trippable BatchFrame (start, body, end)"},{"id":"115","name":"wrapBatch — nested batches supports an outer batch wrapping an already-batched inner sequence"}],"source":"import { describe, expect, it } from 'vitest';\nimport type { RawLine } from '../src/effects';\nimport { type BatchFrame, wrapBatch } from '../src/protocol/batch';\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// wrapBatch — IRCv3 batch framing\n// ============================================================================\n\ndescribe('wrapBatch — framing', () => {\n it('wraps a non-empty line list with BATCH +id / -id markers', () => {\n const lines = [L(':srv 353 alice = #foo :bob'), L(':srv 366 alice #foo :End of /NAMES list.')];\n\n const out = wrapBatch(lines, 'abc', 'names');\n\n expect(out).toEqual<"+"RawLine[]>([\n L('BATCH +abc names'),\n L(':srv 353 alice = #foo :bob'),\n L(':srv 366 alice #foo :End of /NAMES list.'),\n L('BATCH -abc'),\n ]);\n });\n\n it('emits BATCH +id type :args when args are supplied', () => {\n const out = wrapBatch([L(':a JOIN #foo')], 'id1', 'netjoin', '#foo');\n\n expect(out[0]?.text).toBe('BATCH +id1 netjoin #foo');\n expect(out[out.length - 1]?.text).toBe('BATCH -id1');\n });\n\n it('returns the input unchanged when the line list is empty', () => {\n const out = wrapBatch([], 'abc', 'names');\n expect(out).toEqual<"+"RawLine[]>([]);\n });\n\n it('returns an empty BatchFrame carrying the framing metadata when lines is empty', () => {\n // Empty batches are spec-forbidden and silently elided, but the returned\n // array still advertises the id/type/start/body/end surface so callers\n // can read the framing shape without a special-case branch.\n const empty = wrapBatch([], 'ref7', 'chathistory', '#foo');\n expect(empty).toHaveLength(0);\n expect(empty.id).toBe('ref7');\n expect(empty.type).toBe('chathistory');\n expect(empty.start).toEqual({ text: '' });\n expect(empty.body).toEqual([]);\n expect(empty.end).toEqual({ text: '' });\n // Non-enumerable props must not leak into JSON serialization.\n expect(JSON.stringify(empty)).toBe('[]');\n });\n\n it('preserves the order of the inner lines', () => {\n const lines = [L('one'), L('two'), L('three'), L('four')];\n const out = wrapBatch(lines, 'x', 'test');\n expect(out.map((l) => l.text)).toEqual([\n 'BATCH +x test',\n 'one',\n 'two',\n 'three',\n 'four',\n 'BATCH -x',\n ]);\n });\n\n it('produces a stable, round-trippable BatchFrame (start, body, end)', () => {\n const frame: BatchFrame = wrapBatch([L('inner')], 'id', 'topic');\n expect(frame.id).toBe('id');\n expect(frame.type).toBe('topic');\n expect(frame.start.text).toBe('BATCH +id topic');\n expect(frame.end.text).toBe('BATCH -id');\n expect(frame.body).toHaveLength(1);\n expect(frame.body[0]?.text).toBe('inner');\n });\n});\n\n// ============================================================================\n// wrapBatch — nested batches\n// ============================================================================\n\ndescribe('wrapBatch — nested batches', () => {\n it('supports an outer batch wrapping an already-batched inner sequence', () => {\n const inner = wrapBatch([L(':a JOIN #foo'), L(':srv 353 a = #foo :a')], 'inner', 'join');\n const outer = wrapBatch(inner, 'outer', 'netjoin');\n\n expect(outer.map((l) => l.text)).toEqual([\n 'BATCH +outer netjoin',\n 'BATCH +inner join',\n ':a JOIN #foo',\n ':srv 353 a = #foo :a',\n 'BATCH -inner',\n 'BATCH -outer',\n ]);\n });\n});\n"},"tests/dropped-s2s-and-obsolete-verbs.test.ts":{"tests":[{"id":"116","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) numerics.ts source no longer defines RPL_LINKS"},{"id":"117","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) numerics.ts source no longer defines RPL_ENDOFLINKS"},{"id":"118","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) numerics.ts source no longer defines ERR_CANTKILLSERVER"},{"id":"119","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) Numerics runtime object has no RPL_LINKS key"},{"id":"120","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) Numerics runtime object has no RPL_ENDOFLINKS key"},{"id":"121","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) Numerics runtime object has no ERR_CANTKILLSERVER key"},{"id":"122","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) numericToName no longer resolves the dropped S2S codes (364, 365, 483)"},{"id":"123","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) numerics.ts source documents the S2S drop"},{"id":"124","name":"dropped S2S verbs (CONNECT / SQUIT / LINKS) ERR_NOSUCHSERVER (402) is retained (used by LUSERS/STATS/TRACE)"},{"id":"125","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numerics.ts source no longer defines RPL_YOURESERVICE"},{"id":"126","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numerics.ts source no longer defines ERR_NOSUCHSERVICE"},{"id":"127","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numerics.ts source no longer defines ERR_SUMMONDISABLED"},{"id":"128","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numerics.ts source no longer defines ERR_USERSDISABLED"},{"id":"129","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) Numerics runtime object has no RPL_YOURESERVICE key"},{"id":"130","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) Numerics runtime object has no ERR_NOSUCHSERVICE key"},{"id":"131","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) Numerics runtime object has no ERR_SUMMONDISABLED key"},{"id":"132","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) Numerics runtime object has no ERR_USERSDISABLED key"},{"id":"133","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numericToName no longer resolves the dropped obsolete codes (383, 408, 445, 446)"},{"id":"134","name":"dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS) numerics.ts source documents the obsolete-verb drop"}],"source":"/**\n * Regression guards for the formally-dropped S2S and obsolete RFC 2812 verbs.\n *\n * - S2S drop: `CONNECT` / `SQUIT` / `LINKS` are server-to-server commands.\n * S2S is an explicit PLAN non-goal (`PLAN.md` §1), so the three verbs are\n * formally dropped (they still return `421 ERR_UNKNOWNCOMMAND` — that is\n * the correct response for an unimplemented command). Their reserved\n * numerics (`RPL_LINKS`, `RPL_ENDOFLINKS`, `ERR_CANTKILLSERVER`) are removed\n * here.\n *\n * - Obsolete-verb drop: `SERVICE` / `SUMMON` / `USERS` are RFC 2812 verbs\n * that were never widely implemented and are effectively dead. They are\n * formally dropped (also still `421`). Their reserved numerics\n * (`RPL_YOURESERVICE`, `ERR_NOSUCHSERVICE`, `ERR_SUMMONDISABLED`,\n * `ERR_USERSDISABLED`) are removed here.\n *\n * Because the drop is \"remove the numeric, keep the 421\", there is no runtime\n * behaviour to assert — these are source-level / registry-level regression\n * guards that prevent the numerics (and the \"deferred verb\" framing) from\n * creeping back in.\n */\n\nimport { describe, expect, it } from 'vitest';\nimport { Numerics, numericToName } from '../src/protocol/numerics';\nimport numericsSource from '../src/protocol/numerics.ts?raw';\n\ndescribe('dropped S2S verbs (CONNECT / SQUIT / LINKS)', () => {\n // S2S is a PLAN non-goal; the three verbs are formally dropped. Their\n // reserved numerics are removed from the registry.\n const droppedS2SNumerics = ['RPL_LINKS', 'RPL_ENDOFLINKS', 'ERR_CANTKILLSERVER'] as const;\n\n it.each(droppedS2SNumerics)('numerics.ts source no longer defines %s', (name) => {\n expect(numericsSource).not.toContain(`${name}:`);\n });\n\n it.each(droppedS2SNumerics)('Numerics runtime object has no %s key', (name) => {\n expect((Numerics as Record<"+"string, unknown>)[name]).toBeUndefined();\n });\n\n it('numericToName no longer resolves the dropped S2S codes (364, 365, 483)', () => {\n expect(numericToName.get(364)).toBeUndefined();\n expect(numericToName.get(365)).toBeUndefined();\n expect(numericToName.get(483)).toBeUndefined();\n });\n\n it('numerics.ts source documents the S2S drop', () => {\n // The drop must be explained where the numerics used to live, so the\n // absence is not mistaken for a gap.\n expect(numericsSource).toMatch(/S2S/);\n expect(numericsSource).toMatch(/PLAN non-goal/);\n });\n\n it('ERR_NOSUCHSERVER (402) is retained (used by LUSERS/STATS/TRACE)', () => {\n // 402 stays because the implemented query verbs use it for remote-server\n // gating.\n expect(Numerics.ERR_NOSUCHSERVER).toBe(402);\n expect(numericToName.get(402)).toBe('ERR_NOSUCHSERVER');\n });\n});\n\ndescribe('dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS)', () => {\n // RFC 2812 obsolete verbs, never widely implemented. Formally dropped;\n // their reserved numerics are removed from the registry.\n const droppedObsoleteNumerics = [\n 'RPL_YOURESERVICE',\n 'ERR_NOSUCHSERVICE',\n 'ERR_SUMMONDISABLED',\n 'ERR_USERSDISABLED',\n ] as const;\n\n it.each(droppedObsoleteNumerics)('numerics.ts source no longer defines %s', (name) => {\n expect(numericsSource).not.toContain(`${name}:`);\n });\n\n it.each(droppedObsoleteNumerics)('Numerics runtime object has no %s key', (name) => {\n expect((Numerics as Record<"+"string, unknown>)[name]).toBeUndefined();\n });\n\n it('numericToName no longer resolves the dropped obsolete codes (383, 408, 445, 446)', () => {\n expect(numericToName.get(383)).toBeUndefined();\n expect(numericToName.get(408)).toBeUndefined();\n expect(numericToName.get(445)).toBeUndefined();\n expect(numericToName.get(446)).toBeUndefined();\n });\n\n it('numerics.ts source documents the obsolete-verb drop', () => {\n expect(numericsSource).toMatch(/RFC 2812/);\n expect(numericsSource).toMatch(/obsolete/);\n });\n});\n"},"tests/message-tags.test.ts":{"tests":[{"id":"135","name":"filterClientTags — cap gating returns the line unchanged for a message-tags client"},{"id":"136","name":"filterClientTags — cap gating strips a single valued client tag (+) for a non-message-tags client"},{"id":"137","name":"filterClientTags — cap gating drops the entire tag section when only client tags were present"},{"id":"138","name":"filterClientTags — cap gating keeps server tags but removes client tags for a legacy client"},{"id":"139","name":"filterClientTags — cap gating keeps the equals-less form of a server tag and drops a valueless client tag"},{"id":"140","name":"filterClientTags — cap gating returns the same object reference when there are no client tags to strip"},{"id":"141","name":"filterClientTags — cap gating returns the line unchanged when it has no tag section"},{"id":"142","name":"filterClientTags — cap gating returns a malformed @-prefixed line with no body unchanged"},{"id":"143","name":"filterClientTags — cap gating does not mutate the input RawLine"},{"id":"144","name":"filterClientTags — cap gating preserves server and client tags together for a message-tags client"},{"id":"145","name":"filterClientTags — cap gating preserves an escaped value within a kept server tag verbatim"},{"id":"146","name":"filterClientTags — draft/typing whitelist preserves +draft/typing for a draft/typing-only recipient"},{"id":"147","name":"filterClientTags — draft/typing whitelist preserves a valueless +draft/typing tag for a draft/typing-only recipient"},{"id":"148","name":"filterClientTags — draft/typing whitelist strips a non-typing client tag even when draft/typing is negotiated"},{"id":"149","name":"filterClientTags — draft/typing whitelist keeps +draft/typing alongside a server tag for a draft/typing-only recipient"},{"id":"150","name":"filterClientTags — draft/typing whitelist drops every client tag (including +draft/typing) for a cap-less recipient"},{"id":"151","name":"filterClientTags — draft/typing whitelist preserves all client tags for a recipient with both message-tags and draft/typing"},{"id":"152","name":"filterClientTags — draft/typing whitelist drops the leading @ when only a non-typing client tag was present"},{"id":"153","name":"filterClientTags — draft/read-marker whitelist preserves +draft/read-marker for a draft/read-marker-only recipient"},{"id":"154","name":"filterClientTags — draft/read-marker whitelist preserves a valueless +draft/read-marker tag for a draft/read-marker-only recipient"},{"id":"155","name":"filterClientTags — draft/read-marker whitelist strips a non-read-marker client tag even when draft/read-marker is negotiated"},{"id":"156","name":"filterClientTags — draft/read-marker whitelist keeps both +draft/typing and +draft/read-marker for a recipient with both draft caps"},{"id":"157","name":"filterClientTags — draft/read-marker whitelist keeps +draft/read-marker alongside a server tag for a draft/read-marker-only recipient"},{"id":"158","name":"filterClientTags — draft/read-marker whitelist drops every client tag (including +draft/read-marker) for a cap-less recipient"},{"id":"159","name":"filterClientTags — draft/read-marker whitelist drops +draft/read-marker for a draft/typing-only recipient"},{"id":"160","name":"filterClientTags — draft/read-marker whitelist preserves all client tags for a recipient with message-tags"},{"id":"161","name":"filterClientTags + applyServerTime composition keeps @time and client tags for a server-time + message-tags recipient"},{"id":"162","name":"filterClientTags + applyServerTime composition keeps @time but strips client tags for a server-time-only recipient"},{"id":"163","name":"filterClientTags + applyServerTime composition drops the client tag and tag section for a cap-less recipient (no server-time added)"},{"id":"164","name":"escapeTagValue — spec escapes escapes semicolon as \\:"},{"id":"165","name":"escapeTagValue — spec escapes escapes space as \\s"},{"id":"166","name":"escapeTagValue — spec escapes escapes backslash as \\\\"},{"id":"167","name":"escapeTagValue — spec escapes escapes CR as \\r"},{"id":"168","name":"escapeTagValue — spec escapes escapes LF as \\n"},{"id":"169","name":"escapeTagValue — spec escapes leaves ordinary characters untouched"},{"id":"170","name":"unescapeTagValue — spec unescapes unescapes \\: to ;"},{"id":"171","name":"unescapeTagValue — spec unescapes unescapes \\s to space"},{"id":"172","name":"unescapeTagValue — spec unescapes unescapes \\\\ to \\"},{"id":"173","name":"unescapeTagValue — spec unescapes unescapes \\r to CR"},{"id":"174","name":"unescapeTagValue — spec unescapes unescapes \\n to LF"},{"id":"175","name":"escapeTagValue / unescapeTagValue round-trip round-trips a string containing every special character"},{"id":"176","name":"escapeTagValue / unescapeTagValue round-trip round-trips arbitrary strings over the special-character alphabet"},{"id":"177","name":"escapeTagValue / unescapeTagValue round-trip serializer escape then parser unescape yields the original value"}],"source":"import fc from 'fast-check';\nimport { describe, expect, it } from 'vitest';\nimport type { RawLine } from '../src/effects';\nimport { applyServerTime, filterClientTags } from '../src/protocol/outbound';\nimport { parse, unescapeTagValue } from '../src/protocol/parser';\nimport { escapeTagValue, serialize } from '../src/protocol/serializer';\n\nconst L = (text: string): RawLine => ({ text });\nconst NOW = 1_705_329_296_789; // 2024-01-15T14:34:56.789Z\n\n// ============================================================================\n// filterClientTags — IRCv3 message-tags client-tag gating\n// ============================================================================\n\ndescribe('filterClientTags — cap gating', () => {\n it('returns the line unchanged for a message-tags client', () => {\n const line = L('@+typing=1 :alice PRIVMSG #foo :hi');\n expect(filterClientTags(line, new Set<"+"string>(['message-tags']))).toBe(line);\n });\n\n it('strips a single valued client tag (+) for a non-message-tags client', () => {\n const line = L('@+typing=1 :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe(':alice PRIVMSG #foo :hi');\n });\n\n it('drops the entire tag section when only client tags were present', () => {\n const line = L('@+away :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe(':alice PRIVMSG #foo :hi');\n expect(out.text.startsWith('@')).toBe(false);\n });\n\n it('keeps server tags but removes client tags for a legacy client', () => {\n const line = L('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe('@account=alice :alice PRIVMSG #foo :hi');\n });\n\n it('keeps the equals-less form of a server tag and drops a valueless client tag', () => {\n const line = L('@+away;msgid=abc :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe('@msgid=abc :alice PRIVMSG #foo :hi');\n });\n\n it('returns the same object reference when there are no client tags to strip', () => {\n const line = L('@account=alice :alice PRIVMSG #foo :hi');\n expect(filterClientTags(line, new Set<"+"string>())).toBe(line);\n });\n\n it('returns the line unchanged when it has no tag section', () => {\n const line = L(':alice PRIVMSG #foo :hi');\n expect(filterClientTags(line, new Set<"+"string>())).toBe(line);\n });\n\n it('returns a malformed @-prefixed line with no body unchanged', () => {\n const line = L('@+typing');\n expect(filterClientTags(line, new Set<"+"string>())).toBe(line);\n });\n\n it('does not mutate the input RawLine', () => {\n const line = L('@+typing=1 :alice PRIVMSG #foo :hi');\n const snapshot = line.text;\n filterClientTags(line, new Set<"+"string>());\n expect(line.text).toBe(snapshot);\n });\n\n it('preserves server and client tags together for a message-tags client', () => {\n const line = L('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>(['message-tags']));\n expect(out.text).toBe('@account=alice;+typing=1 :alice PRIVMSG #foo :hi');\n });\n\n it('preserves an escaped value within a kept server tag verbatim', () => {\n const line = L('@label=a\\\\sb;+typing=1 :alice PRIVMSG #foo :hi');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe('@label=a\\\\sb :alice PRIVMSG #foo :hi');\n });\n});\n\n// ============================================================================\n// filterClientTags — draft/typing client-tag whitelist\n//\n// IRCv3 `draft/typing` carves out an exception to the message-tags cap\n// requirement: a peer that announced `draft/typing` (but NOT message-tags)\n// still receives the `+draft/typing` client tag on TAGMSG/PRIVMSG lines.\n// Other client tags (`+foo`, `+typing` without the `draft/` prefix, …)\n// remain stripped for non-message-tags recipients.\n// ============================================================================\n\ndescribe('filterClientTags — draft/typing whitelist', () => {\n it('preserves +draft/typing for a draft/typing-only recipient', () => {\n const line = L('@+draft/typing=active :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');\n });\n\n it('preserves a valueless +draft/typing tag for a draft/typing-only recipient', () => {\n const line = L('@+draft/typing :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@+draft/typing :alice TAGMSG #foo');\n });\n\n it('strips a non-typing client tag even when draft/typing is negotiated', () => {\n const line = L('@+typing=1;+draft/typing=active :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');\n });\n\n it('keeps +draft/typing alongside a server tag for a draft/typing-only recipient', () => {\n const line = L('@msgid=abc;+draft/typing=active :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@msgid=abc;+draft/typing=active :alice TAGMSG #foo');\n });\n\n it('drops every client tag (including +draft/typing) for a cap-less recipient', () => {\n const line = L('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe(':alice TAGMSG #foo');\n });\n\n it('preserves all client tags for a recipient with both message-tags and draft/typing', () => {\n const line = L('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['message-tags', 'draft/typing']));\n expect(out.text).toBe('@+draft/typing=active;+foo=bar :alice TAGMSG #foo');\n });\n\n it('drops the leading @ when only a non-typing client tag was present', () => {\n const line = L('@+foo=bar;+draft/typing=active :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');\n });\n});\n\n// ============================================================================\n// filterClientTags — draft/read-marker client-tag whitelist\n//\n// IRCv3 `draft/read-marker` carves out the same exception `draft/typing`\n// does: a peer that announced `draft/read-marker` (but NOT message-tags)\n// still receives the `+draft/read-marker` client tag on TAGMSG lines so the\n// read-marker update reaches the user's other connections. Other client\n// tags remain stripped for non-message-tags recipients.\n// ============================================================================\n\ndescribe('filterClientTags — draft/read-marker whitelist', () => {\n it('preserves +draft/read-marker for a draft/read-marker-only recipient', () => {\n const line = L('@+draft/read-marker=m42 :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/read-marker']));\n expect(out.text).toBe('@+draft/read-marker=m42 :alice TAGMSG #foo');\n });\n\n it('preserves a valueless +draft/read-marker tag for a draft/read-marker-only recipient', () => {\n const line = L('@+draft/read-marker :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/read-marker']));\n expect(out.text).toBe('@+draft/read-marker :alice TAGMSG #foo');\n });\n\n it('strips a non-read-marker client tag even when draft/read-marker is negotiated', () => {\n const line = L('@+foo=bar;+draft/read-marker=m1 :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/read-marker']));\n expect(out.text).toBe('@+draft/read-marker=m1 :alice TAGMSG #foo');\n });\n\n it('keeps both +draft/typing and +draft/read-marker for a recipient with both draft caps', () => {\n const line = L('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing', 'draft/read-marker']));\n expect(out.text).toBe('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');\n });\n\n it('keeps +draft/read-marker alongside a server tag for a draft/read-marker-only recipient', () => {\n const line = L('@msgid=abc;+draft/read-marker=m1 :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/read-marker']));\n expect(out.text).toBe('@msgid=abc;+draft/read-marker=m1 :alice TAGMSG #foo');\n });\n\n it('drops every client tag (including +draft/read-marker) for a cap-less recipient', () => {\n const line = L('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>());\n expect(out.text).toBe(':alice TAGMSG #foo');\n });\n\n it('drops +draft/read-marker for a draft/typing-only recipient', () => {\n const line = L('@+draft/typing=active;+draft/read-marker=m1 :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['draft/typing']));\n expect(out.text).toBe('@+draft/typing=active :alice TAGMSG #foo');\n });\n\n it('preserves all client tags for a recipient with message-tags', () => {\n const line = L('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');\n const out = filterClientTags(line, new Set<"+"string>(['message-tags', 'draft/read-marker']));\n expect(out.text).toBe('@+draft/read-marker=m1;+foo=bar :alice TAGMSG #foo');\n });\n});\n\n// ============================================================================\n// filterClientTags + applyServerTime composition (dispatch-layer ordering)\n// ============================================================================\n\ndescribe('filterClientTags + applyServerTime composition', () => {\n const base = L('@+typing=1 :alice PRIVMSG #foo :hi');\n\n it('keeps @time and client tags for a server-time + message-tags recipient', () => {\n const caps = new Set<"+"string>(['server-time', 'message-tags']);\n const out = filterClientTags(applyServerTime(base, caps, NOW), caps);\n expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z;+typing=1 :alice PRIVMSG #foo :hi');\n });\n\n it('keeps @time but strips client tags for a server-time-only recipient', () => {\n const caps = new Set<"+"string>(['server-time']);\n const out = filterClientTags(applyServerTime(base, caps, NOW), caps);\n expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z :alice PRIVMSG #foo :hi');\n });\n\n it('drops the client tag and tag section for a cap-less recipient (no server-time added)', () => {\n const caps = new Set<"+"string>();\n const out = filterClientTags(applyServerTime(base, caps, NOW), caps);\n expect(out.text).toBe(':alice PRIVMSG #foo :hi');\n });\n});\n\n// ============================================================================\n// escapeTagValue / unescapeTagValue codec round-trip\n// ============================================================================\n\ndescribe('escapeTagValue — spec escapes', () => {\n it('escapes semicolon as \\\\:', () => {\n expect(escapeTagValue(';')).toBe('\\\\:');\n });\n\n it('escapes space as \\\\s', () => {\n expect(escapeTagValue(' ')).toBe('\\\\s');\n });\n\n it('escapes backslash as \\\\\\\\', () => {\n expect(escapeTagValue('\\\\')).toBe('\\\\\\\\');\n });\n\n it('escapes CR as \\\\r', () => {\n expect(escapeTagValue('\\r')).toBe('\\\\r');\n });\n\n it('escapes LF as \\\\n', () => {\n expect(escapeTagValue('\\n')).toBe('\\\\n');\n });\n\n it('leaves ordinary characters untouched', () => {\n expect(escapeTagValue('abc123_.-+/')).toBe('abc123_.-+/');\n });\n});\n\ndescribe('unescapeTagValue — spec unescapes', () => {\n it('unescapes \\\\: to ;', () => {\n expect(unescapeTagValue('\\\\:')).toBe(';');\n });\n\n it('unescapes \\\\s to space', () => {\n expect(unescapeTagValue('\\\\s')).toBe(' ');\n });\n\n it('unescapes \\\\\\\\ to \\\\', () => {\n expect(unescapeTagValue('\\\\\\\\')).toBe('\\\\');\n });\n\n it('unescapes \\\\r to CR', () => {\n expect(unescapeTagValue('\\\\r')).toBe('\\r');\n });\n\n it('unescapes \\\\n to LF', () => {\n expect(unescapeTagValue('\\\\n')).toBe('\\n');\n });\n});\n\ndescribe('escapeTagValue / unescapeTagValue round-trip', () => {\n it('round-trips a string containing every special character', () => {\n const original = 'a b;c\\\\d\\re\\nf';\n expect(unescapeTagValue(escapeTagValue(original))).toBe(original);\n });\n\n it('round-trips arbitrary strings over the special-character alphabet', () => {\n const arb = fc.stringOf(fc.constantFrom('\\\\', ';', ' ', 's', 'r', 'n', ':', 'a', 'b', '1'), {\n maxLength: 32,\n });\n fc.assert(\n fc.property(arb, (s) => {\n expect(unescapeTagValue(escapeTagValue(s))).toBe(s);\n }),\n { numRuns: 500 },\n );\n });\n\n it('serializer escape then parser unescape yields the original value', () => {\n const value = 'hi ; there \\\\ done';\n const wire = serialize({ tags: { k: value }, command: 'PING', params: [] });\n expect(parse(wire).tags.k).toBe(value);\n });\n});\n"},"tests/outbound.test.ts":{"tests":[{"id":"178","name":"formatServerTime formats epoch ms as ISO-8601 with millisecond precision and a Z suffix"},{"id":"179","name":"formatServerTime zero-pads month, day, hour, minute, second, and millisecond fields"},{"id":"180","name":"formatServerTime always emits exactly three millisecond digits"},{"id":"181","name":"formatServerTime rounds/truncates consistently with Date#toISOString"},{"id":"182","name":"applyServerTime prepends @time=<"+"iso> when the recipient negotiated server-time"},{"id":"183","name":"applyServerTime returns the line unchanged when the recipient lacks the server-time cap"},{"id":"184","name":"applyServerTime returns the line unchanged when the recipient has no caps at all"},{"id":"185","name":"applyServerTime prepends to numeric replies too (server-time applies to all outbound messages)"},{"id":"186","name":"applyServerTime does not mutate the input RawLine"},{"id":"187","name":"applyServerTime merges into an existing tag section instead of emitting two @ prefixes"},{"id":"188","name":"enforceLineLimit passes a short line through unchanged for a legacy client"},{"id":"189","name":"enforceLineLimit passes a short line through unchanged for a message-tags client"},{"id":"190","name":"enforceLineLimit truncates a body-only line that exceeds the wire budget for a legacy client"},{"id":"191","name":"enforceLineLimit counts the tag section toward the wire budget for message-tags clients"},{"id":"192","name":"enforceLineLimit preserves the tag section verbatim while truncating only the trailing body"},{"id":"193","name":"enforceLineLimit does not strip tags from a message-tags client even when the line is very long"},{"id":"194","name":"enforceLineLimit handles a legacy client line that already has tags (defensive — never delivered)"},{"id":"195","name":"enforceLineLimit truncates from the right when a message-tags client sends a body with no @ prefix"},{"id":"196","name":"enforceLineLimit truncates from the right when a message-tags line has no space after the tag section"},{"id":"197","name":"enforceLineLimit truncates the tag section itself when it alone exceeds the budget"},{"id":"198","name":"enforceLineLimit — WsFrameMode budget accepts an optional spec-text mode and caps at MAX_WS_MESSAGE_BYTES"},{"id":"199","name":"enforceLineLimit — WsFrameMode budget accepts an optional spec-binary mode and caps at MAX_WS_MESSAGE_BYTES"},{"id":"200","name":"enforceLineLimit — WsFrameMode budget uses the legacy budget when mode is explicitly \"legacy\""},{"id":"201","name":"enforceLineLimit — WsFrameMode budget omitting mode defaults to the legacy budget (backward compatible)"},{"id":"202","name":"enforceLineLimit — WsFrameMode budget preserves the tag section in spec-text mode just as in legacy mode"},{"id":"203","name":"server-time + tag escaping round-trip (property test) parse(serialize(msg)) preserves the time tag across all generated values"},{"id":"204","name":"server-time + tag escaping round-trip (property test) applyServerTime produces a line parseable back into the time tag"}],"source":"import fc from 'fast-check';\nimport { describe, expect, it } from 'vitest';\nimport type { RawLine } from '../src/effects';\nimport {\n MAX_LINE_BYTES,\n applyServerTime,\n enforceLineLimit,\n formatServerTime,\n} from '../src/protocol/outbound';\nimport { parse } from '../src/protocol/parser';\nimport { serialize } from '../src/protocol/serializer';\nimport { MAX_WS_MESSAGE_BYTES } from '../src/ws-framing';\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// formatServerTime — ISO-8601 with milliseconds\n// ============================================================================\n\ndescribe('formatServerTime', () => {\n it('formats epoch ms as ISO-8601 with millisecond precision and a Z suffix', () => {\n // 2024-01-15T14:34:56.789Z\n expect(formatServerTime(1_705_329_296_789)).toBe('2024-01-15T14:34:56.789Z');\n });\n\n it('zero-pads month, day, hour, minute, second, and millisecond fields', () => {\n // 2023-01-01T00:00:00.000Z\n expect(formatServerTime(1_672_531_200_000)).toBe('2023-01-01T00:00:00.000Z');\n });\n\n it('always emits exactly three millisecond digits', () => {\n expect(formatServerTime(1_672_531_200_000)).toMatch(/\\.\\d{3}Z$/u);\n expect(formatServerTime(1_672_531_200_500)).toMatch(/\\.\\d{3}Z$/u);\n expect(formatServerTime(1_672_531_200_999)).toMatch(/\\.\\d{3}Z$/u);\n });\n\n it('rounds/truncates consistently with Date#toISOString', () => {\n const now = 1_705_329_296_789;\n expect(formatServerTime(now)).toBe(new Date(now).toISOString());\n });\n});\n\n// ============================================================================\n// applyServerTime — per-recipient @time=... prepending\n// ============================================================================\n\ndescribe('applyServerTime', () => {\n const PRIVMSG_LINE: RawLine = L(':alice!alice@example.com PRIVMSG #foo :hello');\n\n it('prepends @time=<"+"iso> when the recipient negotiated server-time', () => {\n const caps = new Set<"+"string>(['server-time']);\n const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);\n expect(out.text).toBe(\n '@time=2024-01-15T14:34:56.789Z :alice!alice@example.com PRIVMSG #foo :hello',\n );\n });\n\n it('returns the line unchanged when the recipient lacks the server-time cap', () => {\n const caps = new Set<"+"string>(['multi-prefix', 'echo-message']);\n const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);\n expect(out).toBe(PRIVMSG_LINE);\n expect(out.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');\n });\n\n it('returns the line unchanged when the recipient has no caps at all', () => {\n const caps = new Set<"+"string>();\n const out = applyServerTime(PRIVMSG_LINE, caps, 1_705_329_296_789);\n expect(out.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');\n });\n\n it('prepends to numeric replies too (server-time applies to all outbound messages)', () => {\n const caps = new Set<"+"string>(['server-time']);\n const numeric: RawLine = L(':irc.example.com 001 alice :Welcome to the network');\n const out = applyServerTime(numeric, caps, 1_672_531_200_000);\n expect(out.text).toBe(\n '@time=2023-01-01T00:00:00.000Z :irc.example.com 001 alice :Welcome to the network',\n );\n });\n\n it('does not mutate the input RawLine', () => {\n const caps = new Set<"+"string>(['server-time']);\n const original: RawLine = { text: ':alice PRIVMSG #foo :hi' };\n const snapshot = original.text;\n applyServerTime(original, caps, 1_705_329_296_789);\n expect(original.text).toBe(snapshot);\n });\n\n it('merges into an existing tag section instead of emitting two @ prefixes', () => {\n // A line that already carries an account tag (e.g. from a message-tags\n // enabled reducer) must get its `time` tag merged in after the `@`.\n const caps = new Set<"+"string>(['server-time']);\n const tagged: RawLine = L('@account=alice :alice PRIVMSG #foo :hi');\n const out = applyServerTime(tagged, caps, 1_705_329_296_789);\n expect(out.text).toBe('@time=2024-01-15T14:34:56.789Z;account=alice :alice PRIVMSG #foo :hi');\n });\n});\n\n// ============================================================================\n// enforceLineLimit — 512-byte wire limit including tags\n// ============================================================================\n\ndescribe('enforceLineLimit', () => {\n // MAX_LINE_BYTES (512) is the wire budget INCLUDING the trailing CR-LF the\n // wire layer appends. enforceLineLimit operates on RawLine.text (no CRLF),\n // so the in-memory text budget is MAX_LINE_BYTES - 2 = 510 bytes.\n const TEXT_BUDGET = MAX_LINE_BYTES - 2;\n\n it('passes a short line through unchanged for a legacy client', () => {\n const line: RawLine = L(':alice PRIVMSG #foo :hi');\n const out = enforceLineLimit(line, new Set<"+"string>());\n expect(out).toBe(line);\n });\n\n it('passes a short line through unchanged for a message-tags client', () => {\n const line: RawLine = L('@time=2024-01-15T12:34:56.789Z :alice PRIVMSG #foo :hi');\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out).toBe(line);\n });\n\n it('truncates a body-only line that exceeds the wire budget for a legacy client', () => {\n const prefix = ':alice PRIVMSG #foo :';\n const padding = 'x'.repeat(TEXT_BUDGET - prefix.length);\n const exact: RawLine = L(`${prefix}${padding}`);\n expect(exact.text.length).toBe(TEXT_BUDGET);\n expect(enforceLineLimit(exact, new Set<"+"string>()).text.length).toBe(TEXT_BUDGET);\n\n const tooLong: RawLine = L(`${prefix}${padding}YYYYY`);\n const out = enforceLineLimit(tooLong, new Set<"+"string>());\n expect(out.text.length).toBe(TEXT_BUDGET);\n });\n\n it('counts the tag section toward the wire budget for message-tags clients', () => {\n const tagPrefix = '@time=2024-01-15T12:34:56.789Z '; // 31 bytes incl trailing space\n const body = `:alice PRIVMSG #foo :${'x'.repeat(490)}`; // prefix(21) + 490 = 511\n const line: RawLine = L(`${tagPrefix}${body}`);\n expect(line.text.length).toBeGreaterThan(TEXT_BUDGET);\n\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.length).toBe(TEXT_BUDGET);\n expect(out.text.startsWith(tagPrefix)).toBe(true);\n });\n\n it('preserves the tag section verbatim while truncating only the trailing body', () => {\n const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';\n const body = `:alice PRIVMSG #foo :${'x'.repeat(500)}`;\n const line: RawLine = L(`${tagPrefix}${body}`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.length).toBe(TEXT_BUDGET);\n expect(out.text.startsWith(tagPrefix)).toBe(true);\n expect(out.text.endsWith('xxx')).toBe(true);\n });\n\n it('does not strip tags from a message-tags client even when the line is very long', () => {\n const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';\n const body = `:alice PRIVMSG #foo :${'x'.repeat(1000)}`;\n const line: RawLine = L(`${tagPrefix}${body}`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.startsWith(tagPrefix)).toBe(true);\n expect(out.text.length).toBe(TEXT_BUDGET);\n });\n\n it('handles a legacy client line that already has tags (defensive — never delivered)', () => {\n // The runtime should never deliver a tagged line to a legacy client, but\n // enforceLineLimit must still keep its output within the budget.\n const line: RawLine = L(`@a=b ${'x'.repeat(600)}`);\n const out = enforceLineLimit(line, new Set<"+"string>());\n expect(out.text.length).toBe(TEXT_BUDGET);\n });\n\n it('truncates from the right when a message-tags client sends a body with no @ prefix', () => {\n // Defensive: caps says message-tags but the line never got decorated.\n // We fall through to the legacy truncation path.\n const line: RawLine = L(`:alice PRIVMSG #foo :${'x'.repeat(600)}`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.length).toBe(TEXT_BUDGET);\n expect(out.text.startsWith(':alice')).toBe(true);\n });\n\n it('truncates from the right when a message-tags line has no space after the tag section', () => {\n // Malformed wire input; defensive truncation.\n const line: RawLine = L(`@a=b${'x'.repeat(600)}`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.length).toBe(TEXT_BUDGET);\n });\n\n it('truncates the tag section itself when it alone exceeds the budget', () => {\n // Pathological: the tag section is so large that no body would fit. The\n // contract (output ≤ budget bytes) must still hold.\n const giantTag = `@${'a'.repeat(700)}`;\n const line: RawLine = L(`${giantTag} :alice PRIVMSG #foo :hi`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']));\n expect(out.text.length).toBe(TEXT_BUDGET);\n });\n});\n\n// ============================================================================\n// enforceLineLimit — budget-aware optional WsFrameMode parameter\n// ============================================================================\n\ndescribe('enforceLineLimit — WsFrameMode budget', () => {\n // The optional `mode` parameter selects the byte budget. Spec modes cap at\n // MAX_WS_MESSAGE_BYTES (510) directly — there is no trailing CRLF on a WS\n // message. Legacy mode caps at MAX_LINE_BYTES - 2 (512 - 2 = 510). Both\n // resolve to 510 today, but the API is mode-aware so a future change to\n // either constant needs no call-site edits.\n const SPEC_BUDGET = MAX_WS_MESSAGE_BYTES;\n\n it('accepts an optional spec-text mode and caps at MAX_WS_MESSAGE_BYTES', () => {\n const exact: RawLine = L('x'.repeat(SPEC_BUDGET));\n expect(enforceLineLimit(exact, new Set<"+"string>(), 'spec-text')).toBe(exact);\n\n const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));\n const out = enforceLineLimit(tooLong, new Set<"+"string>(), 'spec-text');\n expect(out.text.length).toBe(SPEC_BUDGET);\n });\n\n it('accepts an optional spec-binary mode and caps at MAX_WS_MESSAGE_BYTES', () => {\n const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));\n const out = enforceLineLimit(tooLong, new Set<"+"string>(), 'spec-binary');\n expect(out.text.length).toBe(SPEC_BUDGET);\n });\n\n it('uses the legacy budget when mode is explicitly \"legacy\"', () => {\n const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));\n const out = enforceLineLimit(tooLong, new Set<"+"string>(), 'legacy');\n expect(out.text.length).toBe(SPEC_BUDGET);\n });\n\n it('omitting mode defaults to the legacy budget (backward compatible)', () => {\n const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));\n const withDefault = enforceLineLimit(tooLong, new Set<"+"string>());\n const withLegacy = enforceLineLimit(tooLong, new Set<"+"string>(), 'legacy');\n expect(withDefault).toEqual(withLegacy);\n });\n\n it('preserves the tag section in spec-text mode just as in legacy mode', () => {\n const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';\n const body = `:alice PRIVMSG #foo :${'x'.repeat(500)}`;\n const line: RawLine = L(`${tagPrefix}${body}`);\n const out = enforceLineLimit(line, new Set<"+"string>(['message-tags']), 'spec-text');\n expect(out.text.length).toBe(SPEC_BUDGET);\n expect(out.text.startsWith(tagPrefix)).toBe(true);\n });\n});\n\n// ============================================================================\n// Round-trip through serialize -> applyServerTime -> parse\n// ============================================================================\n\ndescribe('server-time + tag escaping round-trip (property test)', () => {\n const tagValue = fc.oneof(fc.constant(''), fc.stringMatching(/^[A-Za-z0-9 :;\\\\]{0,32}$/));\n const tagKey = fc.stringMatching(/^[a-z][a-z0-9/+\\\\-]{0,14}$/);\n\n it('parse(serialize(msg)) preserves the time tag across all generated values', () => {\n fc.assert(\n fc.property(fc.tuple(tagKey, tagValue), ([key, value]) => {\n const msg = {\n tags: value === '' ? { [key]: '' } : { [key]: value },\n command: 'PRIVMSG' as const,\n params: ['#foo', 'hello'],\n };\n const wire = serialize(msg);\n const reParsed = parse(wire);\n expect(reParsed.tags).toEqual(msg.tags);\n }),\n { numRuns: 200 },\n );\n });\n\n it('applyServerTime produces a line parseable back into the time tag', () => {\n const base: RawLine = L(':alice PRIVMSG #foo :hi');\n fc.assert(\n fc.property(fc.integer({ min: 0, max: 4_000_000_000_000 }), (now) => {\n const caps = new Set<"+"string>(['server-time']);\n const decorated = applyServerTime(base, caps, now);\n // The decorated line must be parseable and contain the time tag we put there.\n const parsed = parse(decorated.text);\n expect(parsed.tags.time).toBe(formatServerTime(now));\n }),\n { numRuns: 100 },\n );\n });\n});\n"},"tests/serializer.test.ts":{"tests":[{"id":"205","name":"serialize — golden vectors emits the trailing colon for a parameter containing a space"},{"id":"206","name":"serialize — golden vectors emits a full nick!user@host source"},{"id":"207","name":"serialize — golden vectors emits a server source (name only)"},{"id":"208","name":"serialize — golden vectors emits an empty trailing parameter as a dangling colon"},{"id":"209","name":"serialize — golden vectors forces trailing for a parameter that starts with a colon (doubled colon)"},{"id":"210","name":"serialize — golden vectors uses the trailing colon even for a single safe parameter"},{"id":"211","name":"serialize — golden vectors emits just the command when there are no parameters"},{"id":"212","name":"serialize — golden vectors preserves a numeric command verbatim"},{"id":"213","name":"serialize — message-tags (IRCv3) emits valued tags before the rest of the message"},{"id":"214","name":"serialize — message-tags (IRCv3) omits the equals sign for a valueless tag"},{"id":"215","name":"serialize — message-tags (IRCv3) escapes a semicolon in a tag value"},{"id":"216","name":"serialize — message-tags (IRCv3) escapes a space in a tag value"},{"id":"217","name":"serialize — message-tags (IRCv3) escapes a backslash in a tag value"},{"id":"218","name":"serialize — message-tags (IRCv3) emits tags, source and body together"},{"id":"219","name":"serializeFrame appends CR LF to a serialized message"},{"id":"220","name":"serialize <"+"-> parse round-trip (property test) parse(serialize(msg)) equals msg for all generated messages"},{"id":"221","name":"serialize <"+"-> parse round-trip (property test) never emits CR, LF or NUL in a serialized (non-frame) line"}],"source":"import fc from 'fast-check';\nimport { describe, expect, it } from 'vitest';\nimport type { IrcMessage, IrcSource } from '../src/protocol/messages';\nimport { parse } from '../src/protocol/parser';\nimport { serialize, serializeFrame } from '../src/protocol/serializer';\n\ndescribe('serialize — golden vectors', () => {\n it('emits the trailing colon for a parameter containing a space', () => {\n expect(serialize({ tags: {}, command: 'PRIVMSG', params: ['#foo', 'hello world'] })).toBe(\n 'PRIVMSG #foo :hello world',\n );\n });\n\n it('emits a full nick!user@host source', () => {\n expect(\n serialize({\n tags: {},\n source: { name: 'nick', user: 'user', host: 'host' },\n command: 'PRIVMSG',\n params: ['#foo', 'hi'],\n }),\n ).toBe(':nick!user@host PRIVMSG #foo :hi');\n });\n\n it('emits a server source (name only)', () => {\n expect(\n serialize({\n tags: {},\n source: { name: 'irc.example.com' },\n command: 'NOTICE',\n params: ['*', 'hi'],\n }),\n ).toBe(':irc.example.com NOTICE * :hi');\n });\n\n it('emits an empty trailing parameter as a dangling colon', () => {\n expect(serialize({ tags: {}, command: 'TOPIC', params: ['#foo', ''] })).toBe('TOPIC #foo :');\n });\n\n it('forces trailing for a parameter that starts with a colon (doubled colon)', () => {\n expect(serialize({ tags: {}, command: 'PRIVMSG', params: ['#foo', ':x'] })).toBe(\n 'PRIVMSG #foo ::x',\n );\n });\n\n it('uses the trailing colon even for a single safe parameter', () => {\n expect(serialize({ tags: {}, command: 'JOIN', params: ['#foo'] })).toBe('JOIN :#foo');\n });\n\n it('emits just the command when there are no parameters', () => {\n expect(serialize({ tags: {}, command: 'PONG', params: [] })).toBe('PONG');\n });\n\n it('preserves a numeric command verbatim', () => {\n expect(serialize({ tags: {}, command: '001', params: ['nick', 'Welcome'] })).toBe(\n '001 nick :Welcome',\n );\n });\n});\n\ndescribe('serialize — message-tags (IRCv3)', () => {\n it('emits valued tags before the rest of the message', () => {\n expect(\n serialize({\n tags: { time: 't', account: 'a' },\n command: 'PRIVMSG',\n params: ['#foo', 'hi'],\n }),\n ).toBe('@time=t;account=a PRIVMSG #foo :hi');\n });\n\n it('omits the equals sign for a valueless tag', () => {\n expect(serialize({ tags: { '+away': '' }, command: 'PRIVMSG', params: ['#foo', 'hi'] })).toBe(\n '@+away PRIVMSG #foo :hi',\n );\n });\n\n it('escapes a semicolon in a tag value', () => {\n expect(serialize({ tags: { a: 'a;b' }, command: 'PING', params: [] })).toBe('@a=a\\\\:b PING');\n });\n\n it('escapes a space in a tag value', () => {\n expect(serialize({ tags: { a: 'a b' }, command: 'PING', params: [] })).toBe('@a=a\\\\sb PING');\n });\n\n it('escapes a backslash in a tag value', () => {\n expect(serialize({ tags: { a: 'a\\\\b' }, command: 'PING', params: [] })).toBe('@a=a\\\\\\\\b PING');\n });\n\n it('emits tags, source and body together', () => {\n expect(\n serialize({\n tags: { account: 'foo' },\n source: { name: 'nick', user: 'u', host: 'h' },\n command: 'PRIVMSG',\n params: ['#foo', 'hi'],\n }),\n ).toBe('@account=foo :nick!u@h PRIVMSG #foo :hi');\n });\n});\n\ndescribe('serializeFrame', () => {\n it('appends CR LF to a serialized message', () => {\n expect(serializeFrame({ tags: {}, command: 'PING', params: ['tok'] })).toBe('PING :tok\\r\\n');\n });\n});\n\ndescribe('serialize <"+"-> parse round-trip (property test)', () => {\n const safeMiddle = fc.stringMatching(/^[A-Za-z0-9._+\\-]{1,8}$/);\n const freeTrailing = fc.stringMatching(/^[A-Za-z0-9._+\\- :;\\\\]{0,24}$/);\n const commandArb = fc.oneof(fc.stringMatching(/^[A-Z]{1,8}$/), fc.stringMatching(/^[0-9]{3}$/));\n const tagKey = fc.stringMatching(/^[a-z0-9][a-z0-9/+\\\\-]{0,14}$/);\n const tagValue = fc.oneof(fc.constant(''), fc.stringMatching(/^[A-Za-z0-9 :;\\\\]{0,16}$/));\n const tagsArb = fc\n .uniqueArray(fc.tuple(tagKey, tagValue), { selector: (e) => e[0], maxLength: 3 })\n .map((entries) => Object.fromEntries(entries) as Record<"+"string, string>);\n const sourceArb = fc\n .record({\n name: fc.stringMatching(/^[A-Za-z0-9][A-Za-z0-9.\\\\-]{0,19}$/),\n user: fc.option(fc.stringMatching(/^[A-Za-z0-9._~+\\\\-]{1,10}$/), { nil: undefined }),\n host: fc.option(fc.stringMatching(/^[A-Za-z0-9.\\\\-]{1,20}$/), { nil: undefined }),\n })\n .map((s): IrcSource => {\n const out: IrcSource = { name: s.name };\n if (s.user !== undefined) out.user = s.user;\n if (s.host !== undefined) out.host = s.host;\n return out;\n });\n const paramsArb = fc\n .tuple(fc.array(safeMiddle, { maxLength: 6 }), fc.option(freeTrailing, { nil: undefined }))\n .map(([mids, last]) => (last === undefined ? mids : [...mids, last]));\n\n const messageArb = fc\n .record({\n tags: tagsArb,\n command: commandArb,\n params: paramsArb,\n source: fc.option(sourceArb, { nil: undefined }),\n })\n .map((m): IrcMessage => {\n const out: IrcMessage = { tags: m.tags, command: m.command, params: m.params };\n if (m.source !== undefined) out.source = m.source;\n return out;\n });\n\n it('parse(serialize(msg)) equals msg for all generated messages', () => {\n fc.assert(\n fc.property(messageArb, (msg) => {\n expect(parse(serialize(msg))).toEqual(msg);\n }),\n { numRuns: 500 },\n );\n });\n\n it('never emits CR, LF or NUL in a serialized (non-frame) line', () => {\n fc.assert(\n fc.property(messageArb, (msg) => {\n const line = serialize(msg);\n expect(line).not.toMatch(/[\\r\\n\\0]/);\n }),\n { numRuns: 500 },\n );\n });\n});\n"},"tests/standard-replies.test.ts":{"tests":[{"id":"222","name":"toStandardReply — 461 ERR_NEEDMOREPARAMS rewrites 461 to FAIL <"+"command> INVALID_PARAMS :description for a cap-enabled client"},{"id":"223","name":"toStandardReply — 403 ERR_NOSUCHCHANNEL rewrites 403 to FAIL JOIN NO_SUCH_CHANNEL <"+"channel> :description"},{"id":"224","name":"toStandardReply — 432 ERR_ERRONEUSNICKNAME rewrites 432 to FAIL NICK INVALID_NICK <"+"nick> :description"},{"id":"225","name":"toStandardReply — 433 ERR_NICKNAMEINUSE rewrites 433 to FAIL NICK NICKNAME_IN_USE <"+"nick> :description"},{"id":"226","name":"toStandardReply — 904 ERR_SASLFAIL rewrites 904 to FAIL AUTHENTICATE SASL_FAILED :description"},{"id":"227","name":"toStandardReply — legacy client gating returns the unchanged 461 numeric when the recipient lacks the cap"},{"id":"228","name":"toStandardReply — legacy client gating returns the unchanged 403 numeric when the recipient lacks the cap"},{"id":"229","name":"toStandardReply — legacy client gating returns the unchanged 433 numeric when the recipient lacks the cap"},{"id":"230","name":"toStandardReply — legacy client gating returns the unchanged 904 numeric when the recipient lacks the cap"},{"id":"231","name":"toStandardReply — non-curated numerics pass through passes a 372 RPL_MOTD line through unchanged for a cap-enabled client"},{"id":"232","name":"toStandardReply — non-curated numerics pass through passes a 001 RPL_WELCOME line through unchanged for a cap-enabled client"},{"id":"233","name":"toStandardReply — non-curated numerics pass through passes a 353 RPL_NAMREPLY line through unchanged for a cap-enabled client"},{"id":"234","name":"toStandardReply — non-numeric lines pass through passes a PRIVMSG line through unchanged for a cap-enabled client"},{"id":"235","name":"toStandardReply — non-numeric lines pass through passes a FAIL line already emitted by a reducer through unchanged"},{"id":"236","name":"toStandardReply — non-numeric lines pass through passes a line without a server prefix through unchanged"},{"id":"237","name":"toStandardReply — malformed numeric lines pass through passes a bare server prefix through unchanged (no space, no code)"},{"id":"238","name":"toStandardReply — malformed numeric lines pass through passes a server prefix with a single trailing token through unchanged"},{"id":"239","name":"toStandardReply — malformed numeric lines pass through passes a server prefix with a non-numeric command token through unchanged"},{"id":"240","name":"toStandardReply — malformed numeric lines pass through passes a curated numeric line with no nick token through unchanged"},{"id":"241","name":"toStandardReply — malformed numeric lines pass through passes a numeric line with trailing whitespace after the code through unchanged"},{"id":"242","name":"toStandardReply — malformed numeric lines pass through passes a numeric line whose head collapses to empty after trim through unchanged"},{"id":"243","name":"toStandardReply — malformed numeric lines pass through passes a curated numeric line with no trailing parameter through unchanged"},{"id":"244","name":"toStandardReply — malformed numeric lines pass through passes a curated numeric line whose command-from-middle has no middle through unchanged"},{"id":"245","name":"toStandardReply — malformed numeric lines pass through passes a curated context-bearing numeric line with no middle through unchanged"},{"id":"246","name":"numericToStandardReply — curated table maps every curated numeric from the ticket to a FAIL"},{"id":"247","name":"numericToStandardReply — curated table leaves a non-curated error numeric unmapped"},{"id":"248","name":"numericToStandardReply — curated table declares a command strategy for every mapping (no ambiguous rows)"},{"id":"249","name":"numericToStandardReply — curated table never declares both commandFromMiddle and contextFromMiddle simultaneously"}],"source":"import { describe, expect, it } from 'vitest';\nimport type { RawLine } from '../src/effects';\nimport { Numerics } from '../src/protocol/numerics';\nimport { numericToStandardReply, toStandardReply } from '../src/protocol/standard-replies';\n\nconst L = (text: string): RawLine => ({ text });\n\nconst CAPS = new Set<"+"string>(['standard-replies']);\nconst NO_CAPS = new Set<"+"string>();\n\nconst SERVER = 'irc.example.com';\n\n/**\n * Builds a numeric line in the canonical `:server CODE nick [middle] :trailing`\n * form this server emits.\n */\nfunction numericLine(\n code: number,\n nick: string,\n middle: string | undefined,\n trailing: string,\n): RawLine {\n const codeStr = code.toString().padStart(3, '0');\n const parts = [`:${SERVER}`, codeStr, nick];\n if (middle !== undefined) parts.push(middle);\n parts.push(`:${trailing}`);\n return L(parts.join(' '));\n}\n\n// ============================================================================\n// toStandardReply — 461 ERR_NEEDMOREPARAMS (the seed case)\n// ============================================================================\n\ndescribe('toStandardReply — 461 ERR_NEEDMOREPARAMS', () => {\n it('rewrites 461 to FAIL <"+"command> INVALID_PARAMS :description for a cap-enabled client', () => {\n // Arrange: the canonical 461 form emitted by every reducer that ships a\n // command name in the middle slot. The cap-enabled client should see\n // the structured FAIL form instead.\n const input = numericLine(\n Numerics.ERR_NEEDMOREPARAMS,\n 'alice',\n 'JOIN',\n 'Not enough parameters',\n );\n\n // Act\n const out = toStandardReply(input, CAPS);\n\n // Assert: middle (JOIN) becomes the FAIL COMMAND; the trailing text is\n // preserved verbatim as the description. The nick token is dropped —\n // standard replies are addressed by routing, not by the nick field.\n expect(out.text).toBe(`:${SERVER} FAIL JOIN INVALID_PARAMS :Not enough parameters`);\n });\n});\n\n// ============================================================================\n// toStandardReply — 403 ERR_NOSUCHCHANNEL\n// ============================================================================\n\ndescribe('toStandardReply — 403 ERR_NOSUCHCHANNEL', () => {\n it('rewrites 403 to FAIL JOIN NO_SUCH_CHANNEL <"+"channel> :description', () => {\n const input = numericLine(\n Numerics.ERR_NOSUCHCHANNEL,\n 'alice',\n '#nonexistent',\n 'No such channel',\n );\n\n const out = toStandardReply(input, CAPS);\n\n // The numeric's middle (the bad channel name) is emitted as the\n // standard-reply context, and the reducer's command (best known as\n // JOIN here — the most common caller) is the FAIL COMMAND.\n expect(out.text).toBe(`:${SERVER} FAIL JOIN NO_SUCH_CHANNEL #nonexistent :No such channel`);\n });\n});\n\n// ============================================================================\n// toStandardReply — 432 ERR_ERRONEUSNICKNAME\n// ============================================================================\n\ndescribe('toStandardReply — 432 ERR_ERRONEUSNICKNAME', () => {\n it('rewrites 432 to FAIL NICK INVALID_NICK <"+"nick> :description', () => {\n const input = numericLine(\n Numerics.ERR_ERRONEUSNICKNAME,\n 'alice',\n 'b@dnick',\n 'Erroneous nickname',\n );\n\n const out = toStandardReply(input, CAPS);\n\n expect(out.text).toBe(`:${SERVER} FAIL NICK INVALID_NICK b@dnick :Erroneous nickname`);\n });\n});\n\n// ============================================================================\n// toStandardReply — 433 ERR_NICKNAMEINUSE\n// ============================================================================\n\ndescribe('toStandardReply — 433 ERR_NICKNAMEINUSE', () => {\n it('rewrites 433 to FAIL NICK NICKNAME_IN_USE <"+"nick> :description', () => {\n const input = numericLine(\n Numerics.ERR_NICKNAMEINUSE,\n 'alice',\n 'bob',\n 'Nickname is already in use',\n );\n\n const out = toStandardReply(input, CAPS);\n\n expect(out.text).toBe(`:${SERVER} FAIL NICK NICKNAME_IN_USE bob :Nickname is already in use`);\n });\n});\n\n// ============================================================================\n// toStandardReply — 904 ERR_SASLFAIL (SASL failure)\n// ============================================================================\n\ndescribe('toStandardReply — 904 ERR_SASLFAIL', () => {\n it('rewrites 904 to FAIL AUTHENTICATE SASL_FAILED :description', () => {\n const input = numericLine(\n Numerics.ERR_SASLFAIL,\n 'alice',\n undefined,\n 'SASL authentication failed',\n );\n\n const out = toStandardReply(input, CAPS);\n\n // 904 has no middle parameter; the structured form carries only the\n // fixed AUTHENTICATE command and the SASL_FAILED code.\n expect(out.text).toBe(`:${SERVER} FAIL AUTHENTICATE SASL_FAILED :SASL authentication failed`);\n });\n});\n\n// ============================================================================\n// toStandardReply — legacy client gating (no standard-replies cap)\n// ============================================================================\n\ndescribe('toStandardReply — legacy client gating', () => {\n it('returns the unchanged 461 numeric when the recipient lacks the cap', () => {\n const input = numericLine(\n Numerics.ERR_NEEDMOREPARAMS,\n 'alice',\n 'JOIN',\n 'Not enough parameters',\n );\n\n // Same line, same object identity — the helper short-circuits before\n // touching the table when the cap is absent.\n const out = toStandardReply(input, NO_CAPS);\n\n expect(out).toBe(input);\n expect(out.text).toBe(`:${SERVER} 461 alice JOIN :Not enough parameters`);\n });\n\n it('returns the unchanged 403 numeric when the recipient lacks the cap', () => {\n const input = numericLine(Numerics.ERR_NOSUCHCHANNEL, 'alice', '#nope', 'No such channel');\n\n const out = toStandardReply(input, NO_CAPS);\n\n expect(out).toBe(input);\n expect(out.text).toBe(`:${SERVER} 403 alice #nope :No such channel`);\n });\n\n it('returns the unchanged 433 numeric when the recipient lacks the cap', () => {\n const input = numericLine(\n Numerics.ERR_NICKNAMEINUSE,\n 'alice',\n 'bob',\n 'Nickname is already in use',\n );\n\n const out = toStandardReply(input, NO_CAPS);\n\n expect(out).toBe(input);\n expect(out.text).toBe(`:${SERVER} 433 alice bob :Nickname is already in use`);\n });\n\n it('returns the unchanged 904 numeric when the recipient lacks the cap', () => {\n const input = numericLine(\n Numerics.ERR_SASLFAIL,\n 'alice',\n undefined,\n 'SASL authentication failed',\n );\n\n const out = toStandardReply(input, NO_CAPS);\n\n expect(out).toBe(input);\n expect(out.text).toBe(`:${SERVER} 904 alice :SASL authentication failed`);\n });\n});\n\n// ============================================================================\n// toStandardReply — non-curated numerics pass through unchanged\n// ============================================================================\n\ndescribe('toStandardReply — non-curated numerics pass through', () => {\n it('passes a 372 RPL_MOTD line through unchanged for a cap-enabled client', () => {\n // Numeric-only families (MOTD lines, NAMES, the welcome block, …) are\n // intentionally left on the numeric form per the spec.\n const input = numericLine(Numerics.RPL_MOTD, 'alice', undefined, '- Welcome to ExampleNet');\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a 001 RPL_WELCOME line through unchanged for a cap-enabled client', () => {\n const input = numericLine(Numerics.RPL_WELCOME, 'alice', undefined, 'Welcome to the network');\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a 353 RPL_NAMREPLY line through unchanged for a cap-enabled client', () => {\n // NAMES replies carry two middle tokens (sigil + channel); they are\n // not in the curated set and must be delivered verbatim.\n const input = numericLine(Numerics.RPL_NAMREPLY, 'alice', '= #foo', 'alice bob @carol');\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n});\n\n// ============================================================================\n// toStandardReply — non-numeric lines pass through unchanged\n// ============================================================================\n\ndescribe('toStandardReply — non-numeric lines pass through', () => {\n it('passes a PRIVMSG line through unchanged for a cap-enabled client', () => {\n const input = L(':alice!alice@example.com PRIVMSG #foo :hi');\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a FAIL line already emitted by a reducer through unchanged', () => {\n // Defensive: a reducer that already emits the structured form must not\n // be re-translated. The leading token after the prefix is `FAIL`, not\n // a three-digit numeric, so the parser rejects it.\n const input = L(`:${SERVER} FAIL JOIN INVALID_PARAMS :Not enough parameters`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a line without a server prefix through unchanged', () => {\n // e.g. a server-to-server PING carrying no leading `:`. Defensive.\n const input = L('PING :ping.example.com');\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n});\n\n// ============================================================================\n// toStandardReply — defensive parsing (malformed numeric lines)\n// ============================================================================\n\ndescribe('toStandardReply — malformed numeric lines pass through', () => {\n it('passes a bare server prefix through unchanged (no space, no code)', () => {\n // `:irc.example.com` alone — every reducer emits at least one trailing\n // token, so this never ships in practice; defensive.\n const input = L(`:${SERVER}`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a server prefix with a single trailing token through unchanged', () => {\n // `:irc.example.com 001` — no second space, so no nick and no trailing.\n const input = L(`:${SERVER} 001`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a server prefix with a non-numeric command token through unchanged', () => {\n // `:server PING …` — second token is not three digits, so this is not\n // a numeric line; pass through verbatim.\n const input = L(`:${SERVER} PING :ping`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a curated numeric line with no nick token through unchanged', () => {\n // `:server 461 :trailing` — no nick between code and trailing. Every\n // reducer emits the nick, so this never ships; defensive.\n const input = L(`:${SERVER} 461 :Not enough parameters`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a numeric line with trailing whitespace after the code through unchanged', () => {\n // `:server 001 ` — secondSpace is found but afterCode is empty. The\n // reducer never emits this; defensive.\n const input = L(`:${SERVER} 001 `);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a numeric line whose head collapses to empty after trim through unchanged', () => {\n // `:server 001 :trailing` — multiple spaces after the code, then the\n // trailing param. The nick slot is empty after trimming; defensive.\n const input = L(`:${SERVER} 001 :trailing`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a curated numeric line with no trailing parameter through unchanged', () => {\n // `:server 461 alice JOIN` — no `:trailing`. The structured reply form\n // requires a description, so the translator bails and the bare numeric\n // ships verbatim (defensive).\n const input = L(`:${SERVER} 461 alice JOIN`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a curated numeric line whose command-from-middle has no middle through unchanged', () => {\n // 461 normally carries the failed command name as its middle param.\n // When the middle is missing, the translator cannot infer the COMMAND\n // token (required by the spec), so it bails and ships the numeric\n // verbatim. Mirrors how some legacy daemons emit `461 nick :msg`.\n const input = L(`:${SERVER} 461 alice :Not enough parameters`);\n\n const out = toStandardReply(input, CAPS);\n\n expect(out).toBe(input);\n });\n\n it('passes a curated context-bearing numeric line with no middle through unchanged', () => {\n // 403 normally carries the bad channel name as its middle param. When\n // the middle is missing, no context token is available, so the FAIL\n // form degrades to `FAIL JOIN NO_SUCH_CHANNEL :description` (no context).\n const input = L(`:${SERVER} 403 alice :No such channel`);\n\n const out = toStandardReply(input, CAPS);\n\n // The structured form is still emitted; only the context slot is\n // elided when the middle is missing.\n expect(out.text).toBe(`:${SERVER} FAIL JOIN NO_SUCH_CHANNEL :No such channel`);\n });\n});\n\n// ============================================================================\n// numericToStandardReply — table data\n// ============================================================================\n\ndescribe('numericToStandardReply — curated table', () => {\n it('maps every curated numeric from the ticket to a FAIL', () => {\n const curated: ReadonlyArray<"+"[number, string]> = [\n [Numerics.ERR_NEEDMOREPARAMS, 'INVALID_PARAMS'],\n [Numerics.ERR_NOSUCHCHANNEL, 'NO_SUCH_CHANNEL'],\n [Numerics.ERR_ERRONEUSNICKNAME, 'INVALID_NICK'],\n [Numerics.ERR_NICKNAMEINUSE, 'NICKNAME_IN_USE'],\n [Numerics.ERR_SASLFAIL, 'SASL_FAILED'],\n ];\n for (const [code, expected] of curated) {\n const mapping = numericToStandardReply.get(code);\n expect(mapping).toBeDefined();\n expect(mapping?.kind).toBe('FAIL');\n expect(mapping?.code).toBe(expected);\n }\n });\n\n it('leaves a non-curated error numeric unmapped', () => {\n expect(numericToStandardReply.has(Numerics.ERR_NOSUCHNICK)).toBe(false);\n expect(numericToStandardReply.has(Numerics.RPL_WELCOME)).toBe(false);\n expect(numericToStandardReply.has(Numerics.ERR_PASSWDMISMATCH)).toBe(false);\n });\n\n it('declares a command strategy for every mapping (no ambiguous rows)', () => {\n // Invariant: every row resolves a COMMAND. When commandFromMiddle is\n // false, a fixed `command` MUST be supplied — otherwise the helper\n // would have to bail at runtime.\n for (const mapping of numericToStandardReply.values()) {\n if (mapping.commandFromMiddle) {\n expect(mapping.contextFromMiddle).toBe(false);\n } else {\n expect(mapping.command).toBeDefined();\n }\n }\n });\n\n it('never declares both commandFromMiddle and contextFromMiddle simultaneously', () => {\n // The two strategies consume the same middle slot; they are mutually\n // exclusive by construction.\n for (const mapping of numericToStandardReply.values()) {\n expect(mapping.commandFromMiddle && mapping.contextFromMiddle).toBe(false);\n }\n });\n});\n"},"tests/commands/away.test.ts":{"tests":[{"id":"250","name":"awayReducer — setting away emits 306 RPL_NOWAWAY to the sender when a reason is given (no away-notify peers)"},{"id":"251","name":"awayReducer — setting away unsets away when no parameter is given, emitting 305 RPL_UNAWAY"},{"id":"252","name":"awayReducer — setting away unsets away when the parameter is empty, emitting 305 RPL_UNAWAY"},{"id":"253","name":"awayReducer — setting away overwrites an existing away reason when a new reason is given"},{"id":"254","name":"awayReducer — setting away updates lastSeen to ctx.clock.now()"},{"id":"255","name":"awayReducer — away-notify cap fanout emits an AWAY broadcast with the reason to cap-enabled peers in shared channels"},{"id":"256","name":"awayReducer — away-notify cap fanout emits an AWAY broadcast with no trailing param when unsetting (away-notify)"},{"id":"257","name":"awayReducer — away-notify cap fanout emits no broadcast when the user has no joined channels (away set)"},{"id":"258","name":"awayReducer — away-notify cap fanout places the AWAY broadcast AFTER the numeric reply"},{"id":"259","name":"awayReducer — away-notify cap fanout uses cap-only broadcast so non-cap peers receive nothing"},{"id":"260","name":"awayReducer — defensive uses * in the numeric reply when the connection has no nick"},{"id":"261","name":"awayReducer — defensive falls back to nick-only hostmask when user/host are absent"},{"id":"262","name":"awayReducer — defensive falls back to ?-prefixed source when nick is also absent"},{"id":"263","name":"awayReducer — draft/pre-away persistence persists the reason to the AwayStore keyed by account when setting away"},{"id":"264","name":"awayReducer — draft/pre-away persistence overwrites the persisted reason when the away reason changes"},{"id":"265","name":"awayReducer — draft/pre-away persistence clears the persisted reason when AWAY is unset (no param)"},{"id":"266","name":"awayReducer — draft/pre-away persistence clears the persisted reason when AWAY is unset (empty param)"},{"id":"267","name":"awayReducer — draft/pre-away persistence does not persist when no AwayStore is bound (in-memory only)"},{"id":"268","name":"awayReducer — draft/pre-away persistence does not persist when the connection is unidentified (no account)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { awayReducer } from '../../src/commands/away';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n type AwayStore,\n EmptyMotdProvider,\n FakeClock,\n InMemoryAwayStore,\n SequentialIdFactory,\n} from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000), away?: AwayStore): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n ...(away !== undefined ? { away } : {}),\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// awayReducer — setting AWAY\n// ============================================================================\n\ndescribe('awayReducer — setting away', () => {\n it('emits 306 RPL_NOWAWAY to the sender when a reason is given (no away-notify peers)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone fishing'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 306 alice :You have been marked as being away')]),\n ]);\n expect(conn.away).toBe('gone fishing');\n });\n\n it('unsets away when no parameter is given, emitting 305 RPL_UNAWAY', () => {\n const conn = makeConn();\n conn.away = 'old reason';\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 305 alice :You are no longer marked as being away')]),\n ]);\n expect(conn.away).toBeUndefined();\n });\n\n it('unsets away when the parameter is empty, emitting 305 RPL_UNAWAY', () => {\n const conn = makeConn();\n conn.away = 'old reason';\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 305 alice :You are no longer marked as being away')]),\n ]);\n expect(conn.away).toBeUndefined();\n });\n\n it('overwrites an existing away reason when a new reason is given', () => {\n const conn = makeConn();\n conn.away = 'old';\n const ctx = makeCtx(conn);\n\n awayReducer(conn, { command: 'AWAY', params: ['new'], tags: {} }, ctx);\n\n expect(conn.away).toBe('new');\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(conn, clock);\n\n awayReducer(conn, { command: 'AWAY', params: ['x'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(7_700);\n });\n});\n\n// ============================================================================\n// awayReducer — away-notify cap fanout\n// ============================================================================\n\ndescribe('awayReducer — away-notify cap fanout', () => {\n it('emits an AWAY broadcast with the reason to cap-enabled peers in shared channels', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n conn.joinedChannels.add('#bar');\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n // Two cap-only broadcasts (one per shared channel), plus the 306 numeric.\n const broadcasts = out.effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcasts).toHaveLength(2);\n expect(broadcasts[0]?.cap).toBe('away-notify');\n expect(broadcasts[0]?.lines).toEqual<"+"RawLine[]>([L(':alice!alice@example.com AWAY :gone')]);\n expect(broadcasts[0]?.capLines).toBeUndefined();\n expect(broadcasts[1]?.lines).toEqual<"+"RawLine[]>([L(':alice!alice@example.com AWAY :gone')]);\n });\n\n it('emits an AWAY broadcast with no trailing param when unsetting (away-notify)', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n conn.away = 'previously away';\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);\n\n const broadcasts = out.effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcasts).toHaveLength(1);\n expect(broadcasts[0]?.lines).toEqual<"+"RawLine[]>([L(':alice!alice@example.com AWAY')]);\n expect(broadcasts[0]?.cap).toBe('away-notify');\n });\n\n it('emits no broadcast when the user has no joined channels (away set)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n const broadcasts = out.effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcasts).toHaveLength(0);\n // The numeric is still emitted.\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n\n it('places the AWAY broadcast AFTER the numeric reply', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n expect(out.effects[0]?.tag).toBe('Send');\n expect(out.effects[1]?.tag).toBe('Broadcast');\n });\n\n it('uses cap-only broadcast so non-cap peers receive nothing', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.cap).toBe('away-notify');\n expect(broadcast?.capLines).toBeUndefined();\n });\n});\n\n// ============================================================================\n// awayReducer — defensive paths\n// ============================================================================\n\ndescribe('awayReducer — defensive', () => {\n it('uses * in the numeric reply when the connection has no nick', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 306 * :You have been marked as being away')]),\n ]);\n });\n\n it('falls back to nick-only hostmask when user/host are absent', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.nick = 'alice';\n conn.registration = 'registered';\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.lines[0]?.text).toBe(':alice AWAY :gone');\n });\n\n it('falls back to ?-prefixed source when nick is also absent', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.lines[0]?.text).toBe(':? AWAY :gone');\n });\n});\n\n// ============================================================================\n// awayReducer — draft/pre-away persistence\n// ============================================================================\n\ndescribe('awayReducer — draft/pre-away persistence', () => {\n it('persists the reason to the AwayStore keyed by account when setting away', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn();\n conn.account = 'alice';\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);\n\n expect(store.get('alice')).toBe('brb');\n });\n\n it('overwrites the persisted reason when the away reason changes', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn();\n conn.account = 'alice';\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);\n awayReducer(conn, { command: 'AWAY', params: ['lunch'], tags: {} }, ctx);\n\n expect(store.get('alice')).toBe('lunch');\n });\n\n it('clears the persisted reason when AWAY is unset (no param)', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const conn = makeConn();\n conn.account = 'alice';\n conn.away = 'brb';\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);\n\n expect(store.get('alice')).toBeUndefined();\n });\n\n it('clears the persisted reason when AWAY is unset (empty param)', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const conn = makeConn();\n conn.account = 'alice';\n conn.away = 'brb';\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);\n\n expect(store.get('alice')).toBeUndefined();\n });\n\n it('does not persist when no AwayStore is bound (in-memory only)', () => {\n const conn = makeConn();\n conn.account = 'alice';\n const ctx = makeCtx(conn); // no store\n\n awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);\n\n // In-memory reason still set so the session's own view is consistent.\n expect(conn.away).toBe('brb');\n });\n\n it('does not persist when the connection is unidentified (no account)', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn(); // no account\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);\n\n expect(store.get('alice')).toBeUndefined();\n });\n});\n"},"tests/commands/cap.test.ts":{"tests":[{"id":"269","name":"capReducer — LS responds with `CAP * LS :<"+"caps>` for an unregistered client"},{"id":"270","name":"capReducer — LS uses the connection nick as the target once registered"},{"id":"271","name":"capReducer — LS accepts a client-advertised CAP LS version number"},{"id":"272","name":"capReducer — LS accepts a lowercase subcommand token"},{"id":"273","name":"capReducer — LS advertises safelist as a supported capability"},{"id":"274","name":"capReducer — LS advertises sasl=PLAIN when no mTLS provider is configured"},{"id":"275","name":"capReducer — LS advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured"},{"id":"276","name":"capReducer — LS advertises draft/multiline=4096 with the default byte budget"},{"id":"277","name":"capReducer — LS advertises the configured multiline byte budget when overridden"},{"id":"278","name":"capReducer — LS marks the connection as in CAP negotiation so the welcome is deferred"},{"id":"279","name":"capReducer — LS refreshes lastSeen"},{"id":"280","name":"capReducer — LS emits multiple CAP LS lines with the multiline `*` marker when the payload overflows one line"},{"id":"281","name":"capReducer — LIST returns an empty list when no caps are negotiated"},{"id":"282","name":"capReducer — LIST returns the currently negotiated caps for a registered client"},{"id":"283","name":"capReducer — LIST does not mutate caps or start CAP negotiation"},{"id":"284","name":"capReducer — LIST emits multiple CAP LIST lines with the `*` marker when negotiated caps overflow one line"},{"id":"285","name":"capReducer — REQ ACKs a single known cap and records it on the connection"},{"id":"286","name":"capReducer — REQ ACKs the safelist cap and records it on the connection"},{"id":"287","name":"capReducer — REQ NAKs a single unknown cap"},{"id":"288","name":"capReducer — REQ ACKs multiple known caps requested in one REQ"},{"id":"289","name":"capReducer — REQ NAKs the entire REQ when any requested cap is unknown (all-or-nothing)"},{"id":"290","name":"capReducer — REQ echoes the disable marker (`-`) in the ACK and removes the cap"},{"id":"291","name":"capReducer — REQ marks the connection as in CAP negotiation so the welcome is deferred"},{"id":"292","name":"capReducer — REQ emits 461 when no caps parameter is supplied"},{"id":"293","name":"capReducer — REQ emits 461 when the caps parameter is an empty trailing string"},{"id":"294","name":"capReducer — REQ targets `*` in ACK when the client has no nick yet"},{"id":"295","name":"capReducer — REQ targets `*` in the 461 reply when the client has no nick yet"},{"id":"296","name":"capReducer — END triggers the welcome sequence when registration is otherwise complete"},{"id":"297","name":"capReducer — END does not emit a welcome when nick is not yet set"},{"id":"298","name":"capReducer — END does not emit a welcome when user is not yet set"},{"id":"299","name":"capReducer — END clears capNegotiating even when registration is incomplete"},{"id":"300","name":"capReducer — END is a no-op when the connection is already registered"},{"id":"301","name":"capReducer — deferred welcome does not fire the welcome on USER while CAP negotiation is in progress"},{"id":"302","name":"capReducer — deferred welcome fires the welcome on CAP END after USER was deferred"},{"id":"303","name":"capReducer — unknown subcommands emits 410 ERR_INVALIDCAPCMD for an unknown subcommand"},{"id":"304","name":"capReducer — unknown subcommands emits 410 for CAP NEW sent from a client (server-to-client only)"},{"id":"305","name":"capReducer — unknown subcommands emits 410 for CAP DEL sent from a client (server-to-client only)"},{"id":"306","name":"capReducer — unknown subcommands emits 410 when the subcommand is missing entirely"},{"id":"307","name":"capReducer — unknown subcommands uses the connection nick in the 410 reply when registered"},{"id":"308","name":"capReducer — STS advertisement omits the sts cap when no STS policy is configured"},{"id":"309","name":"capReducer — STS advertisement advertises sts=duration=…,port=… on a plaintext connection"},{"id":"310","name":"capReducer — STS advertisement reads duration and port from ServerConfig (not literals)"},{"id":"311","name":"capReducer — STS advertisement appends preload when configured on a plaintext connection"},{"id":"312","name":"capReducer — STS advertisement advertises only duration on a TLS connection (wss / irc+tls)"},{"id":"313","name":"capReducer — STS advertisement omits preload on a TLS connection even when configured"},{"id":"314","name":"capReducer — STS advertisement ACKs CAP REQ sts when configured"},{"id":"315","name":"capReducer — STS advertisement NAKs CAP REQ sts when no STS policy is configured"}],"source":"import { describe, expect, it } from 'vitest';\nimport { getLsString } from '../../src/caps/capabilities';\nimport { capReducer } from '../../src/commands/cap';\nimport { userReducer } from '../../src/commands/registration';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport type { MtlsIdentityProvider } from '../../src/ports';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n state: ConnectionState,\n cfg: ServerConfig = serverConfig,\n mtlsIdentity?: MtlsIdentityProvider,\n): Ctx {\n return buildCtx({\n serverConfig: cfg,\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),\n });\n}\n\nfunction makeState(): ConnectionState {\n return createConnection({ id: 'c1', connectedSince: 0 });\n}\n\n/** Convenience state with nick+user set, still pre-welcome, not yet CAP-gated. */\nfunction readyState(): ConnectionState {\n const s = makeState();\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registering';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n/** Expected CAP LS body (single-line form, bare list of caps). */\nconst EXPECTED_LS = getLsString();\n\n// ============================================================================\n// capReducer — LS\n// ============================================================================\n\ndescribe('capReducer — LS', () => {\n it('responds with `CAP * LS :<"+"caps>` for an unregistered client', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com CAP * LS :${EXPECTED_LS}`)]),\n ]);\n });\n\n it('uses the connection nick as the target once registered', () => {\n const state = readyState();\n state.registration = 'registered';\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain('CAP alice LS :');\n }\n });\n\n it('accepts a client-advertised CAP LS version number', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS', '302'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain('CAP * LS :');\n }\n });\n\n it('accepts a lowercase subcommand token', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['ls'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com CAP * LS :${EXPECTED_LS}`)]),\n ]);\n });\n\n it('advertises safelist as a supported capability', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('safelist');\n // safelist has no value — must not appear in `name=value` form.\n expect(body).not.toContain('safelist=');\n }\n });\n\n it('advertises sasl=PLAIN when no mTLS provider is configured', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sasl=PLAIN');\n expect(body).not.toContain('sasl=PLAIN,EXTERNAL');\n }\n });\n\n it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {\n const state = makeState();\n const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };\n const ctx = makeCtx(state, serverConfig, provider);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sasl=PLAIN,EXTERNAL');\n }\n });\n\n it('advertises draft/multiline=4096 with the default byte budget', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('draft/multiline=4096');\n }\n });\n\n it('advertises the configured multiline byte budget when overridden', () => {\n const state = makeState();\n const cfg: ServerConfig = { ...serverConfig, multilineMaxBytes: 8192 };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('draft/multiline=8192');\n expect(body).not.toContain('draft/multiline=4096');\n }\n });\n\n it('marks the connection as in CAP negotiation so the welcome is deferred', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n expect(out.state.capNegotiating).toBe(true);\n });\n\n it('refreshes lastSeen', () => {\n const state = makeState();\n const clock = new FakeClock(5_000);\n const ctx = buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n expect(out.state.lastSeen).toBe(5_000);\n });\n\n it('emits multiple CAP LS lines with the multiline `*` marker when the payload overflows one line', () => {\n const longName = `s${'x'.repeat(460)}`;\n const cfg: ServerConfig = { ...serverConfig, serverName: longName };\n const state = makeState();\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n expect(send.lines.length).toBeGreaterThan(1);\n for (let i = 0; i <"+" send.lines.length - 1; i++) {\n const line = send.lines[i]?.text ?? '';\n expect(line).toContain(' LS * :');\n }\n const last = send.lines[send.lines.length - 1]?.text ?? '';\n expect(last).toContain(' LS :');\n expect(last).not.toContain(' LS * :');\n }\n });\n});\n\n// ============================================================================\n// capReducer — LIST\n// ============================================================================\n\ndescribe('capReducer — LIST', () => {\n it('returns an empty list when no caps are negotiated', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP * LIST :')]),\n ]);\n });\n\n it('returns the currently negotiated caps for a registered client', () => {\n const state = readyState();\n state.registration = 'registered';\n state.caps.add('server-time');\n state.caps.add('multi-prefix');\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('CAP alice LIST :');\n expect(body).toContain('server-time');\n expect(body).toContain('multi-prefix');\n }\n });\n\n it('does not mutate caps or start CAP negotiation', () => {\n const state = makeState();\n state.caps.add('echo-message');\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);\n expect(out.state.caps).toEqual(new Set(['echo-message']));\n expect(out.state.capNegotiating).toBe(false);\n });\n\n it('emits multiple CAP LIST lines with the `*` marker when negotiated caps overflow one line', () => {\n const longName = `s${'x'.repeat(460)}`;\n const cfg: ServerConfig = { ...serverConfig, serverName: longName };\n const state = makeState();\n state.caps.add('server-time');\n state.caps.add('multi-prefix');\n state.caps.add('echo-message');\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LIST'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n expect(send.lines.length).toBeGreaterThan(1);\n for (let i = 0; i <"+" send.lines.length - 1; i++) {\n expect(send.lines[i]?.text).toContain(' LIST * :');\n }\n expect(send.lines[send.lines.length - 1]?.text).toContain(' LIST :');\n }\n });\n});\n\n// ============================================================================\n// capReducer — REQ\n// ============================================================================\n\ndescribe('capReducer — REQ', () => {\n it('ACKs a single known cap and records it on the connection', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(\n state,\n { command: 'CAP', params: ['REQ', 'server-time'], tags: {} },\n ctx,\n );\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP alice ACK :server-time')]),\n ]);\n expect(out.state.caps.has('server-time')).toBe(true);\n });\n\n it('ACKs the safelist cap and records it on the connection', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ', 'safelist'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP alice ACK :safelist')]),\n ]);\n expect(out.state.caps.has('safelist')).toBe(true);\n });\n\n it('NAKs a single unknown cap', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ', 'nope'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP alice NAK :nope')]),\n ]);\n expect(out.state.caps.has('nope')).toBe(false);\n });\n\n it('ACKs multiple known caps requested in one REQ', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(\n state,\n { command: 'CAP', params: ['REQ', 'server-time multi-prefix'], tags: {} },\n ctx,\n );\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('ACK');\n expect(body).toContain('server-time');\n expect(body).toContain('multi-prefix');\n }\n expect(out.state.caps.has('server-time')).toBe(true);\n expect(out.state.caps.has('multi-prefix')).toBe(true);\n });\n\n it('NAKs the entire REQ when any requested cap is unknown (all-or-nothing)', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(\n state,\n { command: 'CAP', params: ['REQ', 'server-time nope'], tags: {} },\n ctx,\n );\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP alice NAK :server-time nope')]),\n ]);\n expect(out.state.caps.has('server-time')).toBe(false);\n expect(out.state.caps.has('nope')).toBe(false);\n });\n\n it('echoes the disable marker (`-`) in the ACK and removes the cap', () => {\n const state = readyState();\n state.caps.add('server-time');\n const ctx = makeCtx(state);\n const out = capReducer(\n state,\n { command: 'CAP', params: ['REQ', '-server-time'], tags: {} },\n ctx,\n );\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP alice ACK :-server-time')]),\n ]);\n expect(out.state.caps.has('server-time')).toBe(false);\n });\n\n it('marks the connection as in CAP negotiation so the welcome is deferred', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n capReducer(state, { command: 'CAP', params: ['REQ', 'server-time'], tags: {} }, ctx);\n expect(state.capNegotiating).toBe(true);\n });\n\n it('emits 461 when no caps parameter is supplied', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice CAP :Not enough parameters')]),\n ]);\n });\n\n it('emits 461 when the caps parameter is an empty trailing string', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ', ''], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice CAP :Not enough parameters')]),\n ]);\n });\n\n it('targets `*` in ACK when the client has no nick yet', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(\n state,\n { command: 'CAP', params: ['REQ', 'server-time'], tags: {} },\n ctx,\n );\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com CAP * ACK :server-time')]),\n ]);\n });\n\n it('targets `*` in the 461 reply when the client has no nick yet', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * CAP :Not enough parameters')]),\n ]);\n });\n});\n\n// ============================================================================\n// capReducer — END\n// ============================================================================\n\ndescribe('capReducer — END', () => {\n it('triggers the welcome sequence when registration is otherwise complete', () => {\n const state = readyState();\n state.capNegotiating = true;\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.state.registration).toBe('registered');\n expect(out.state.capNegotiating).toBe(false);\n const welcome = out.effects[0];\n expect(welcome).toBeDefined();\n if (welcome?.tag === 'Send') {\n expect(welcome.lines[0]?.text).toContain('001 alice');\n }\n });\n\n it('does not emit a welcome when nick is not yet set', () => {\n const state = makeState();\n state.user = 'alice';\n state.realname = 'Alice';\n state.capNegotiating = true;\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.state.registration).not.toBe('registered');\n expect(out.effects).toEqual([]);\n expect(out.state.capNegotiating).toBe(false);\n });\n\n it('does not emit a welcome when user is not yet set', () => {\n const state = makeState();\n state.nick = 'alice';\n state.capNegotiating = true;\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.state.registration).not.toBe('registered');\n expect(out.effects).toEqual([]);\n });\n\n it('clears capNegotiating even when registration is incomplete', () => {\n const state = makeState();\n state.capNegotiating = true;\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.state.capNegotiating).toBe(false);\n });\n\n it('is a no-op when the connection is already registered', () => {\n const state = readyState();\n state.registration = 'registered';\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.effects).toEqual([]);\n expect(out.state.registration).toBe('registered');\n });\n});\n\n// ============================================================================\n// capReducer — deferred welcome integration with NICK/USER\n// ============================================================================\n\ndescribe('capReducer — deferred welcome', () => {\n it('does not fire the welcome on USER while CAP negotiation is in progress', () => {\n const state = makeState();\n state.nick = 'alice';\n state.capNegotiating = true;\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(out.state.registration).toBe('registering');\n expect(out.effects).toEqual([]);\n });\n\n it('fires the welcome on CAP END after USER was deferred', () => {\n const state = makeState();\n state.nick = 'alice';\n state.capNegotiating = true;\n state.registration = 'registering';\n const ctx = makeCtx(state);\n userReducer(state, { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} }, ctx);\n const out = capReducer(state, { command: 'CAP', params: ['END'], tags: {} }, ctx);\n expect(out.state.registration).toBe('registered');\n const welcome = out.effects[0];\n expect(welcome).toBeDefined();\n if (welcome?.tag === 'Send') {\n expect(welcome.lines[0]?.text).toContain('001 alice');\n }\n });\n});\n\n// ============================================================================\n// capReducer — unknown / invalid subcommands\n// ============================================================================\n\ndescribe('capReducer — unknown subcommands', () => {\n it('emits 410 ERR_INVALIDCAPCMD for an unknown subcommand', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['BOGUS'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 410 * BOGUS :Invalid CAP command')]),\n ]);\n });\n\n it('emits 410 for CAP NEW sent from a client (server-to-client only)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['NEW'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 410 * NEW :Invalid CAP command')]),\n ]);\n });\n\n it('emits 410 for CAP DEL sent from a client (server-to-client only)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['DEL'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 410 * DEL :Invalid CAP command')]),\n ]);\n });\n\n it('emits 410 when the subcommand is missing entirely', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 410 * :Invalid CAP command')]),\n ]);\n });\n\n it('uses the connection nick in the 410 reply when registered', () => {\n const state = readyState();\n state.registration = 'registered';\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['BOGUS'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 410 alice BOGUS :Invalid CAP command')]),\n ]);\n });\n});\n\n// ============================================================================\n// capReducer — IRCv3 `sts` (Strict Transport Security) advertisement\n// ============================================================================\n\ndescribe('capReducer — STS advertisement', () => {\n it('omits the sts cap when no STS policy is configured', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).not.toContain('sts=');\n expect(body).not.toContain(' sts ');\n }\n });\n\n it('advertises sts=duration=…,port=… on a plaintext connection', () => {\n const state = makeState();\n state.secure = false;\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 600, port: 6697 },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sts=duration=600,port=6697');\n }\n });\n\n it('reads duration and port from ServerConfig (not literals)', () => {\n const state = makeState();\n state.secure = false;\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 12345, port: 7000 },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sts=duration=12345,port=7000');\n expect(body).not.toContain('sts=duration=600');\n expect(body).not.toContain('port=6697');\n }\n });\n\n it('appends preload when configured on a plaintext connection', () => {\n const state = makeState();\n state.secure = false;\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 600, port: 6697, preload: true },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sts=duration=600,port=6697,preload');\n }\n });\n\n it('advertises only duration on a TLS connection (wss / irc+tls)', () => {\n const state = makeState();\n state.secure = true;\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 600, port: 6697 },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sts=duration=600');\n expect(body).not.toContain('port=');\n expect(body).not.toContain('preload');\n }\n });\n\n it('omits preload on a TLS connection even when configured', () => {\n const state = makeState();\n state.secure = true;\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 600, port: 6697, preload: true },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('sts=duration=600');\n expect(body).not.toContain('preload');\n }\n });\n\n it('ACKs CAP REQ sts when configured', () => {\n const state = readyState();\n const cfg: ServerConfig = {\n ...serverConfig,\n sts: { duration: 600, port: 6697 },\n };\n const ctx = makeCtx(state, cfg);\n const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('ACK :sts');\n }\n expect(out.state.caps.has('sts')).toBe(true);\n });\n\n it('NAKs CAP REQ sts when no STS policy is configured', () => {\n const state = readyState();\n const ctx = makeCtx(state);\n const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);\n const send = out.effects[0];\n expect(send).toBeDefined();\n if (send?.tag === 'Send') {\n const body = send.lines[0]?.text ?? '';\n expect(body).toContain('NAK :sts');\n }\n expect(out.state.caps.has('sts')).toBe(false);\n });\n});\n"},"tests/commands/chathistory.test.ts":{"tests":[{"id":"316","name":"chathistoryReducer — cap gating rejects with 421 ERR_UNKNOWNCOMMAND when the cap is not negotiated"},{"id":"317","name":"chathistoryReducer — cap gating does not query the store when the cap is missing (no leak / no work)"},{"id":"318","name":"chathistoryReducer — LATEST returns the most recent N messages in a chathistory BATCH, oldest first, each tagged @time+msgid"},{"id":"319","name":"chathistoryReducer — LATEST honors a known msgid pivot for LATEST (returns up to and including the pivot)"},{"id":"320","name":"chathistoryReducer — LATEST uses the channel display name (original case) in replay lines and batch args"},{"id":"321","name":"chathistoryReducer — LATEST returns fewer than the limit when history is shorter (no error)"},{"id":"322","name":"chathistoryReducer — LATEST emits nothing when the channel has no history (empty batch elided)"},{"id":"323","name":"chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN BEFORE returns the N messages preceding the pivot msgid"},{"id":"324","name":"chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN AFTER returns the N messages following the pivot msgid"},{"id":"325","name":"chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN AROUND returns messages spanning the pivot"},{"id":"326","name":"chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN BETWEEN returns messages after m1 up to and including m2"},{"id":"327","name":"chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN bounds at the start/end return fewer than the limit with no error"},{"id":"328","name":"chathistoryReducer — BEFORE read-marker fallback falls back to the last-read marker when BEFORE omits the pivot"},{"id":"329","name":"chathistoryReducer — BEFORE read-marker fallback honours an explicit pivot over the marker when both are supplied"},{"id":"330","name":"chathistoryReducer — BEFORE read-marker fallback returns 461 when BEFORE omits the pivot and no marker is recorded"},{"id":"331","name":"chathistoryReducer — BEFORE read-marker fallback returns an empty batch when the marker points at an evicted msgid"},{"id":"332","name":"chathistoryReducer — TARGETS enumerates channels with recent activity for TARGETS * *"},{"id":"333","name":"chathistoryReducer — TARGETS honors an explicit ISO-timestamp window"},{"id":"334","name":"chathistoryReducer — TARGETS emits nothing when no channel has activity"},{"id":"335","name":"chathistoryReducer — error paths returns 403 ERR_NOSUCHCHANNEL for a non-existent channel"},{"id":"336","name":"chathistoryReducer — error paths returns 461 ERR_NEEDMOREPARAMS for an unknown pivot msgid (BEFORE)"},{"id":"337","name":"chathistoryReducer — error paths returns 461 for an unknown msgid in AFTER"},{"id":"338","name":"chathistoryReducer — error paths returns 461 for an unknown msgid in BETWEEN (either bound)"},{"id":"339","name":"chathistoryReducer — error paths returns 461 ERR_NEEDMOREPARAMS when the subcommand is missing"},{"id":"340","name":"chathistoryReducer — error paths returns 461 when a channel subcommand is missing its target parameter"},{"id":"341","name":"chathistoryReducer — error paths returns 461 for an unknown subcommand"},{"id":"342","name":"chathistoryReducer — error paths returns 461 for an unparseable TARGETS timestamp"},{"id":"343","name":"chathistoryReducer — error paths returns 461 when BETWEEN is missing one of its two msgid bounds"},{"id":"344","name":"chathistoryReducer — error paths returns 461 when BEFORE is missing its pivot msgid"},{"id":"345","name":"chathistoryReducer — error paths returns 461 when the limit argument is not a non-negative integer"},{"id":"346","name":"chathistoryReducer — error paths returns 461 when LATEST is asked to pivot on an unknown msgid"},{"id":"347","name":"chathistoryReducer — error paths returns 461 when TARGETS is missing its until-bound argument"},{"id":"348","name":"chathistoryReducer — +s/+p leak prevention returns an empty result (no batch) when the requester is not on a +s channel"},{"id":"349","name":"chathistoryReducer — +s/+p leak prevention returns an empty result when the requester is not on a +p channel"},{"id":"350","name":"chathistoryReducer — +s/+p leak prevention serves history to a +s member normally"},{"id":"351","name":"chathistoryReducer — TAGMSG replay replays a stored TAGMSG without a trailing text parameter"},{"id":"352","name":"chathistoryReducer — TAGMSG replay replays a message whose stored hostmask has only a user (no host)"},{"id":"353","name":"chathistoryReducer — TAGMSG replay replays a message whose stored source is a bare nick (no user, no host)"},{"id":"354","name":"chathistoryReducer — TAGMSG replay replays a message whose stored source has a host but no user (cloaked webchat shape)"},{"id":"355","name":"chathistoryReducer — TAGMSG replay LATEST without an explicit limit argument uses the default 50 cap"},{"id":"356","name":"chathistoryReducer — no MessageStore bound LATEST on an empty store elides the batch (no replay, no error)"},{"id":"357","name":"chathistoryReducer — no MessageStore bound TARGETS on an empty store emits nothing"},{"id":"358","name":"chathistoryReducer — no MessageStore bound BEFORE on an unbound store returns invalid-params (storeHas fallback)"},{"id":"359","name":"chathistoryReducer — unregistered connection formats numeric errors with `*` when the connection has no nick"},{"id":"360","name":"formatReplayLine — clientTags appends +draft/multiline to the tag section when clientTags are present"},{"id":"361","name":"formatReplayLine — clientTags omits the client-tag section entirely when clientTags is undefined"},{"id":"362","name":"formatReplayLine — clientTags omits the client-tag section when clientTags is empty"}],"source":"import { describe, expect, it } from 'vitest';\nimport { chathistoryReducer, formatReplayLine } from '../../src/commands/chathistory';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryMessageStore,\n type MessageStore,\n SequentialIdFactory,\n type StoredMessage,\n} from '../../src/ports';\nimport { formatServerTime } from '../../src/protocol/outbound';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\n/** Caps the connection has negotiated; defaults to the chathistory cap set. */\nfunction makeConn(\n id = 'c1',\n nick = 'alice',\n caps: string[] = ['draft/chathistory', 'server-time', 'message-tags', 'batch'],\n): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n for (const c of caps) s.caps.add(c);\n return s;\n}\n\nfunction makeCtx(conn: ConnectionState, store: MessageStore, clock = new FakeClock(10_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n messages: store,\n connection: conn,\n });\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\n/** Records N chronological PRIVMSG messages into the store. */\nfunction seed(store: MessageStore, chan: string, count: number): StoredMessage[] {\n const out: StoredMessage[] = [];\n for (let i = 1; i <"+"= count; i++) {\n const m: StoredMessage = {\n msgid: `m${i}`,\n time: i * 1_000,\n chan: chan.toLowerCase(),\n command: 'PRIVMSG',\n nick: 'bob',\n user: 'bob',\n host: 'ex.org',\n text: `msg ${i}`,\n };\n store.record(m);\n out.push(m);\n }\n return out;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n/** Expected replay line for a stored PRIVMSG. */\nfunction replayLine(m: StoredMessage, displayChan: string): RawLine {\n const stamp = formatServerTime(m.time);\n return {\n text: `@time=${stamp};msgid=${m.msgid} :bob!bob@ex.org PRIVMSG ${displayChan} :${m.text}`,\n };\n}\n\n/** Builds the expected BATCH frame for a list of stored messages. */\nfunction expectedBatch(body: RawLine[], batchId: string, displayChan: string): EffectType[] {\n if (body.length === 0) return [];\n const start = L(`BATCH +${batchId} chathistory ${displayChan}`);\n const end = L(`BATCH -${batchId}`);\n return [Effect.send('c1', [start, ...body, end])];\n}\n\n// ============================================================================\n// cap gating\n// ============================================================================\n\ndescribe('chathistoryReducer — cap gating', () => {\n it('rejects with 421 ERR_UNKNOWNCOMMAND when the cap is not negotiated', () => {\n const conn = makeConn('c1', 'alice', []); // no caps\n const store = new InMemoryMessageStore();\n const chan = makeChan();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 421 alice CHATHISTORY :Unknown command')]),\n ]);\n });\n\n it('does not query the store when the cap is missing (no leak / no work)', () => {\n let queried = false;\n const spyStore: MessageStore = {\n record(): void {\n /* noop */\n },\n query(): StoredMessage[] {\n queried = true;\n return [];\n },\n recent(): StoredMessage[] {\n queried = true;\n return [];\n },\n hasMsgid(): boolean {\n return true;\n },\n targets(): { chan: string; time: number }[] {\n return [];\n },\n };\n const conn = makeConn('c1', 'alice', []);\n const chan = makeChan();\n const ctx = makeCtx(conn, spyStore);\n\n chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },\n ctx,\n );\n\n expect(queried).toBe(false);\n });\n});\n\n// ============================================================================\n// LATEST\n// ============================================================================\n\ndescribe('chathistoryReducer — LATEST', () => {\n it('returns the most recent N messages in a chathistory BATCH, oldest first, each tagged @time+msgid', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },\n ctx,\n );\n\n const body = [\n replayLine(seeded[3] as StoredMessage, '#foo'),\n replayLine(seeded[4] as StoredMessage, '#foo'),\n ];\n expect(out.effects).toEqual<"+"EffectType[]>(expectedBatch(body, 'batch-0', '#foo'));\n });\n\n it('honors a known msgid pivot for LATEST (returns up to and including the pivot)', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n // Real msgid pivot (m3) exercises the pivotArg !== undefined branch.\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', 'm3', '5'], tags: {} },\n ctx,\n );\n\n // Spec: LATEST with a known pivot returns the most recent N messages\n // up to *and including* the pivot, oldest first.\n const body = [\n replayLine(seeded[0] as StoredMessage, '#foo'),\n replayLine(seeded[1] as StoredMessage, '#foo'),\n replayLine(seeded[2] as StoredMessage, '#foo'),\n ];\n expect(out.effects).toEqual<"+"EffectType[]>(expectedBatch(body, 'batch-0', '#foo'));\n });\n\n it('uses the channel display name (original case) in replay lines and batch args', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#Foo', 1);\n const conn = makeConn();\n const chan = makeChan('#Foo');\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch([replayLine(seeded[0] as StoredMessage, '#Foo')], 'batch-0', '#Foo'),\n );\n });\n\n it('returns fewer than the limit when history is shorter (no error)', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 2);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '50'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[0] as StoredMessage, '#foo'),\n replayLine(seeded[1] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('emits nothing when the channel has no history (empty batch elided)', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '50'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n});\n\n// ============================================================================\n// BEFORE / AFTER / AROUND / BETWEEN\n// ============================================================================\n\ndescribe('chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN', () => {\n it('BEFORE returns the N messages preceding the pivot msgid', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm4', '2'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[1] as StoredMessage, '#foo'),\n replayLine(seeded[2] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('AFTER returns the N messages following the pivot msgid', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['AFTER', '#foo', 'm2', '2'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[2] as StoredMessage, '#foo'),\n replayLine(seeded[3] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('AROUND returns messages spanning the pivot', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['AROUND', '#foo', 'm3', '3'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[1] as StoredMessage, '#foo'),\n replayLine(seeded[2] as StoredMessage, '#foo'),\n replayLine(seeded[3] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('BETWEEN returns messages after m1 up to and including m2', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1', 'm4'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[1] as StoredMessage, '#foo'),\n replayLine(seeded[2] as StoredMessage, '#foo'),\n replayLine(seeded[3] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('bounds at the start/end return fewer than the limit with no error', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm1', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]); // nothing before the first message\n void seeded;\n });\n});\n\n// ============================================================================\n// BEFORE — draft/read-marker fallback (no explicit pivot)\n// ============================================================================\n//\n// IRCv3 draft/read-marker integration: when a cap-enabled client omits the\n// pivot msgid on CHATHISTORY BEFORE, the reducer falls back to the\n// connection's per-channel last-read marker. This lets a client resume\n// reading \"everything before my last read position\" without re-supplying the\n// msgid. When no marker is recorded either, the reducer keeps the legacy\n// 461 ERR_NEEDMOREPARAMS behaviour.\n// ============================================================================\n\ndescribe('chathistoryReducer — BEFORE read-marker fallback', () => {\n it('falls back to the last-read marker when BEFORE omits the pivot', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n conn.lastReadMarkers = new Map<"+"string, string>([['#foo', 'm4']]);\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[0] as StoredMessage, '#foo'),\n replayLine(seeded[1] as StoredMessage, '#foo'),\n replayLine(seeded[2] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('honours an explicit pivot over the marker when both are supplied', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 5);\n const conn = makeConn();\n // Marker points at m4, but the client explicitly asks for messages before m3.\n conn.lastReadMarkers = new Map<"+"string, string>([['#foo', 'm4']]);\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm3', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [\n replayLine(seeded[0] as StoredMessage, '#foo'),\n replayLine(seeded[1] as StoredMessage, '#foo'),\n ],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('returns 461 when BEFORE omits the pivot and no marker is recorded', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn(); // no marker\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns an empty batch when the marker points at an evicted msgid', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n // Marker references a message the store no longer retains; BEFORE must\n // not surface an INVALID_PARAMS error, just an empty result.\n conn.lastReadMarkers = new Map<"+"string, string>([['#foo', 'evicted']]);\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n});\n\n// ============================================================================\n// TARGETS\n// ============================================================================\n\ndescribe('chathistoryReducer — TARGETS', () => {\n it('enumerates channels with recent activity for TARGETS * *', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 1); // time 1000\n store.record({\n msgid: 'bx',\n time: 5_000,\n chan: '#bar',\n command: 'PRIVMSG',\n nick: 'b',\n text: 'x',\n });\n const conn = makeConn();\n const ctx = makeCtx(conn, store, new FakeClock(100_000));\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L('BATCH +batch-0 chathistory'),\n L(`:irc.example.com CHATHISTORY TARGETS ${formatServerTime(5_000)} #bar`),\n L(`:irc.example.com CHATHISTORY TARGETS ${formatServerTime(1_000)} #foo`),\n L('BATCH -batch-0'),\n ]),\n ]);\n });\n\n it('honors an explicit ISO-timestamp window', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 1); // time 1000\n store.record({\n msgid: 'bx',\n time: 9_000,\n chan: '#bar',\n command: 'PRIVMSG',\n nick: 'b',\n text: 'x',\n });\n const conn = makeConn();\n const ctx = makeCtx(conn, store, new FakeClock(100_000));\n\n const sinceIso = formatServerTime(2_000);\n const untilIso = formatServerTime(5_000);\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['TARGETS', sinceIso, untilIso], tags: {} },\n ctx,\n );\n\n // Only #foo@1000 is outside [2000,5000]; #bar@9000 is outside too → empty.\n expect(out.effects).toEqual([]);\n });\n\n it('emits nothing when no channel has activity', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n});\n\n/** Channel arg is unused for TARGETS; supply a throwaway. */\nfunction chan0(): ChannelState {\n return makeChan('#0');\n}\n\n// ============================================================================\n// error paths\n// ============================================================================\n\ndescribe('chathistoryReducer — error paths', () => {\n it('returns 403 ERR_NOSUCHCHANNEL for a non-existent channel', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n undefined,\n { command: 'CHATHISTORY', params: ['LATEST', '#nope', '*', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #nope :No such channel')]),\n ]);\n });\n\n it('returns 461 ERR_NEEDMOREPARAMS for an unknown pivot msgid (BEFORE)', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'missing', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice CHATHISTORY :Invalid parameters')]),\n ]);\n });\n\n it('returns 461 for an unknown msgid in AFTER', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['AFTER', '#foo', 'ghost', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 for an unknown msgid in BETWEEN (either bound)', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1', 'ghost'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 ERR_NEEDMOREPARAMS when the subcommand is missing', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(chan0(), { command: 'CHATHISTORY', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice CHATHISTORY :Not enough parameters')]),\n ]);\n });\n\n it('returns 461 when a channel subcommand is missing its target parameter', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['LATEST'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 for an unknown subcommand', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['BOGUS', '#foo', '*', '10'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 for an unparseable TARGETS timestamp', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['TARGETS', 'not-a-date', '*'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 when BETWEEN is missing one of its two msgid bounds', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 when BEFORE is missing its pivot msgid', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 when the limit argument is not a non-negative integer', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', 'abc'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 when LATEST is asked to pivot on an unknown msgid', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', 'ghost', '5'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n\n it('returns 461 when TARGETS is missing its until-bound argument', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan0(),\n { command: 'CHATHISTORY', params: ['TARGETS', '*'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n});\n\n// ============================================================================\n// secret / private leak prevention\n// ============================================================================\n\ndescribe('chathistoryReducer — +s/+p leak prevention', () => {\n it('returns an empty result (no batch) when the requester is not on a +s channel', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.modes.secret = true;\n // requester NOT a member\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('returns an empty result when the requester is not on a +p channel', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 3);\n const conn = makeConn();\n const chan = makeChan();\n chan.modes.private = true;\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('serves history to a +s member normally', () => {\n const store = new InMemoryMessageStore();\n const seeded = seed(store, '#foo', 1);\n const conn = makeConn();\n const chan = makeChan();\n chan.modes.secret = true;\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch([replayLine(seeded[0] as StoredMessage, '#foo')], 'batch-0', '#foo'),\n );\n });\n});\n\n// ============================================================================\n// TAGMSG replay formatting\n// ============================================================================\n\ndescribe('chathistoryReducer — TAGMSG replay', () => {\n it('replays a stored TAGMSG without a trailing text parameter', () => {\n const store = new InMemoryMessageStore();\n const tagmsg: StoredMessage = {\n msgid: 't1',\n time: 1_000,\n chan: '#foo',\n command: 'TAGMSG',\n nick: 'bob',\n user: 'bob',\n host: 'ex.org',\n text: '',\n };\n store.record(tagmsg);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n const stamp = formatServerTime(tagmsg.time);\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch([L(`@time=${stamp};msgid=t1 :bob!bob@ex.org TAGMSG #foo`)], 'batch-0', '#foo'),\n );\n });\n\n it('replays a message whose stored hostmask has only a user (no host)', () => {\n const store = new InMemoryMessageStore();\n const userOnly: StoredMessage = {\n msgid: 'u1',\n time: 1_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'eve',\n user: 'eve',\n // host omitted → replay line source is `eve!eve`, not `eve!eve@...`\n text: 'hi',\n };\n store.record(userOnly);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n const stamp = formatServerTime(userOnly.time);\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch([L(`@time=${stamp};msgid=u1 :eve!eve PRIVMSG #foo :hi`)], 'batch-0', '#foo'),\n );\n });\n\n it('replays a message whose stored source is a bare nick (no user, no host)', () => {\n const store = new InMemoryMessageStore();\n const bare: StoredMessage = {\n msgid: 'b1',\n time: 2_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'webchat',\n // user + host both omitted → replay line source is just `webchat`\n text: 'hello',\n };\n store.record(bare);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n const stamp = formatServerTime(bare.time);\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch([L(`@time=${stamp};msgid=b1 :webchat PRIVMSG #foo :hello`)], 'batch-0', '#foo'),\n );\n });\n\n it('replays a message whose stored source has a host but no user (cloaked webchat shape)', () => {\n const store = new InMemoryMessageStore();\n const hostOnly: StoredMessage = {\n msgid: 'h1',\n time: 3_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'guest',\n // user omitted, host defined → exercises the else of `if (m.user !== undefined)`\n host: 'gateway.example.com',\n text: 'hi',\n };\n store.record(hostOnly);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n const stamp = formatServerTime(hostOnly.time);\n expect(out.effects).toEqual<"+"EffectType[]>(\n expectedBatch(\n [L(`@time=${stamp};msgid=h1 :guest@gateway.example.com PRIVMSG #foo :hi`)],\n 'batch-0',\n '#foo',\n ),\n );\n });\n\n it('LATEST without an explicit limit argument uses the default 50 cap', () => {\n const store = new InMemoryMessageStore();\n seed(store, '#foo', 2);\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtx(conn, store);\n\n const out = chathistoryReducer(\n chan,\n // Three params only — exercises `parseLimit(undefined) → DEFAULT_QUERY_LIMIT`.\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n const send = out.effects[0] as { lines: RawLine[] };\n // BATCH start + 2 replay lines + BATCH end = 4 lines.\n expect(send.lines).toHaveLength(4);\n });\n});\n\n// ============================================================================\n// No MessageStore bound (deployment disabled chathistory persistence)\n// ============================================================================\n\ndescribe('chathistoryReducer — no MessageStore bound', () => {\n /** Builds a Ctx with no `messages` port, exercising the `?? fallback` branches. */\n function makeCtxNoStore(conn: ConnectionState): Ctx {\n return buildCtx({\n serverConfig,\n clock: new FakeClock(10_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n }\n\n it('LATEST on an empty store elides the batch (no replay, no error)', () => {\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtxNoStore(conn);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('TARGETS on an empty store emits nothing', () => {\n const conn = makeConn();\n const ctx = makeCtxNoStore(conn);\n\n const out = chathistoryReducer(\n makeChan(),\n { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('BEFORE on an unbound store returns invalid-params (storeHas fallback)', () => {\n const conn = makeConn();\n const chan = makeChan();\n chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });\n const ctx = makeCtxNoStore(conn);\n\n const out = chathistoryReducer(\n chan,\n { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm1', '5'], tags: {} },\n ctx,\n );\n\n // No store → storeHas returns false → invalid-params 461 line.\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');\n });\n});\n\n// ============================================================================\n// Unregistered connection (numericErr fallback to `*`)\n// ============================================================================\n\ndescribe('chathistoryReducer — unregistered connection', () => {\n it('formats numeric errors with `*` when the connection has no nick', () => {\n const store = new InMemoryMessageStore();\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n // Caps negotiated but no NICK yet — exercises numericErr's `?? '*'` branch.\n conn.caps.add('draft/chathistory');\n conn.caps.add('server-time');\n conn.caps.add('message-tags');\n conn.caps.add('batch');\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(10_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n messages: store,\n connection: conn,\n });\n\n const out = chathistoryReducer(\n makeChan(),\n { command: 'CHATHISTORY', params: ['LATEST'], tags: {} },\n ctx,\n );\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toBe(\n ':irc.example.com 461 * CHATHISTORY :Not enough parameters',\n );\n });\n});\n\n// ===========================================================================\n// formatReplayLine — clientTags on chathistory replay\n//\n// A stored message carrying `clientTags` (e.g. `+draft/multiline` from a\n// multi-line batch) must surface those tags in the chathistory replay so\n// message-tags-aware clients can recognise the message kind.\n// ===========================================================================\n\ndescribe('formatReplayLine — clientTags', () => {\n const baseMsg: StoredMessage = {\n msgid: 'abc123',\n time: 1_700_000_000_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'alice',\n user: 'alice',\n host: 'example.com',\n text: 'line one\\nline two',\n };\n\n it('appends +draft/multiline to the tag section when clientTags are present', () => {\n const out = formatReplayLine({ ...baseMsg, clientTags: ['+draft/multiline'] }, '#foo');\n expect(out.text).toBe(\n '@time=2023-11-14T22:13:20.000Z;msgid=abc123;+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\\nline two',\n );\n });\n\n it('omits the client-tag section entirely when clientTags is undefined', () => {\n const out = formatReplayLine(baseMsg, '#foo');\n expect(out.text).toBe(\n '@time=2023-11-14T22:13:20.000Z;msgid=abc123 :alice!alice@example.com PRIVMSG #foo :line one\\nline two',\n );\n });\n\n it('omits the client-tag section when clientTags is empty', () => {\n const out = formatReplayLine({ ...baseMsg, clientTags: [] }, '#foo');\n expect(out.text).not.toContain('+draft/multiline');\n });\n});\n"},"tests/commands/echo-message.test.ts":{"tests":[{"id":"363","name":"echo-message — channel PRIVMSG does NOT echo when the sender lacks the echo-message cap"},{"id":"364","name":"echo-message — channel PRIVMSG echoes the PRIVMSG back to the sender AFTER the broadcast when the cap is set"},{"id":"365","name":"echo-message — channel PRIVMSG echoes NOTICE back to the sender when the cap is set"},{"id":"366","name":"echo-message — channel PRIVMSG does not echo on error paths (no recipient)"},{"id":"367","name":"echo-message — channel PRIVMSG does not echo on +m rejection"},{"id":"368","name":"echo-message — user PRIVMSG does NOT echo when the sender lacks the echo-message cap"},{"id":"369","name":"echo-message — user PRIVMSG echoes the PRIVMSG back to the sender AFTER SendToNick when the cap is set"},{"id":"370","name":"echo-message — user PRIVMSG echoes NOTICE back to the sender when the cap is set"},{"id":"371","name":"echo-message — user PRIVMSG does not echo on missing-text error path"}],"source":"import { describe, expect, it } from 'vitest';\nimport {\n noticeChannelReducer,\n noticeUserReducer,\n privmsgChannelReducer,\n privmsgUserReducer,\n} from '../../src/commands/privmsg';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice', caps: ReadonlyArray<"+"string> = []): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n for (const c of caps) s.caps.add(c);\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(chan: ChannelState, connId: string, nick: string): void {\n chan.members.set(connId, { conn: connId, nick, op: false, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// echo-message — channel path\n// ============================================================================\n\ndescribe('echo-message — channel PRIVMSG', () => {\n it('does NOT echo when the sender lacks the echo-message cap', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [\n L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),\n ]),\n ]);\n });\n\n it('echoes the PRIVMSG back to the sender AFTER the broadcast when the cap is set', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [\n L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),\n ]),\n Effect.send('c1', [L(':alice!alice@example.com PRIVMSG #foo :hi')]),\n ]);\n });\n\n it('echoes NOTICE back to the sender when the cap is set', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1', 'msgid', [\n L('@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :hi'),\n ]),\n Effect.send('c1', [L(':alice!alice@example.com NOTICE #foo :hi')]),\n ]);\n });\n\n it('does not echo on error paths (no recipient)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(chan, { command: 'PRIVMSG', params: [], tags: {} }, ctx);\n\n // Only the 411 error; no broadcast, no echo.\n expect(out.effects.length).toBe(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n\n it('does not echo on +m rejection', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice'); // not opped/voiced\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n // Only the 404 error; no broadcast, no echo.\n expect(out.effects.length).toBe(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n});\n\n// ============================================================================\n// echo-message — user (PM) path\n// ============================================================================\n\ndescribe('echo-message — user PRIVMSG', () => {\n it('does NOT echo when the sender lacks the echo-message cap', () => {\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(\n conn,\n { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },\n ctx,\n );\n\n // Just SendToNick; no echo.\n expect(out.effects.length).toBe(1);\n expect(out.effects[0]?.tag).toBe('SendToNick');\n });\n\n it('echoes the PRIVMSG back to the sender AFTER SendToNick when the cap is set', () => {\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(\n conn,\n { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects.length).toBe(2);\n expect(out.effects[0]?.tag).toBe('SendToNick');\n expect(out.effects[1]).toEqual(\n Effect.send('c1', [L(':alice!alice@example.com PRIVMSG bob :hi')]),\n );\n });\n\n it('echoes NOTICE back to the sender when the cap is set', () => {\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = noticeUserReducer(\n conn,\n { command: 'NOTICE', params: ['bob', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects.length).toBe(2);\n expect(out.effects[0]?.tag).toBe('SendToNick');\n expect(out.effects[1]).toEqual(\n Effect.send('c1', [L(':alice!alice@example.com NOTICE bob :hi')]),\n );\n });\n\n it('does not echo on missing-text error path', () => {\n const conn = makeConn('c1', 'alice', ['echo-message']);\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob'], tags: {} }, ctx);\n\n // Only the 412 error; no SendToNick, no echo.\n expect(out.effects.length).toBe(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n});\n"},"tests/commands/extended-join.test.ts":{"tests":[{"id":"372","name":"extended-join — JOIN broadcast format emits a cap-split broadcast with the extended JOIN line for cap-enabled recipients"},{"id":"373","name":"extended-join — JOIN broadcast format uses the authenticated account name when the joiner has one"},{"id":"374","name":"extended-join — JOIN broadcast format uses _ as the account when the joiner is not authenticated"},{"id":"375","name":"extended-join — JOIN broadcast format still emits the legacy JOIN line for non-cap recipients"},{"id":"376","name":"extended-join — JOIN broadcast format includes the joiner in the broadcast (no except)"},{"id":"377","name":"extended-join — JOIN broadcast format omits @host from the extended JOIN prefix when host is absent"},{"id":"378","name":"extended-join — JOIN broadcast format falls back to legacy-only broadcast when realname is absent"}],"source":"import { describe, expect, it } from 'vitest';\nimport { joinReducer } from '../../src/commands/join';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\ndescribe('extended-join — JOIN broadcast format', () => {\n it('emits a cap-split broadcast with the extended JOIN line for cap-enabled recipients', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com JOIN #foo')],\n undefined,\n 'extended-join',\n [L(':alice!alice@example.com JOIN #foo _ :Alice')],\n ),\n );\n });\n\n it('uses the authenticated account name when the joiner has one', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n conn.account = 'alice-account';\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com JOIN #foo')],\n undefined,\n 'extended-join',\n [L(':alice!alice@example.com JOIN #foo alice-account :Alice')],\n ),\n );\n });\n\n it('uses _ as the account when the joiner is not authenticated', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.cap).toBe('extended-join');\n expect(broadcast?.capLines?.[0]?.text).toBe(':alice!alice@example.com JOIN #foo _ :Alice');\n });\n\n it('still emits the legacy JOIN line for non-cap recipients', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.lines).toEqual<"+"RawLine[]>([L(':alice!alice@example.com JOIN #foo')]);\n });\n\n it('includes the joiner in the broadcast (no except)', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n conn.caps.add('extended-join');\n const ctx = makeCtx(conn);\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.except).toBeUndefined();\n });\n\n it('omits @host from the extended JOIN prefix when host is absent', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.\n delete conn.host;\n const ctx = makeCtx(conn);\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.capLines?.[0]?.text).toBe(':alice!alice JOIN #foo _ :Alice');\n });\n\n it('falls back to legacy-only broadcast when realname is absent', () => {\n const chan = makeChan('#foo');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.nick = '?';\n const ctx = makeCtx(conn);\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.cap).toBeUndefined();\n expect(broadcast?.capLines).toBeUndefined();\n expect(broadcast?.lines).toEqual<"+"RawLine[]>([L(':? JOIN #foo')]);\n });\n});\n"},"tests/commands/invite-notify.test.ts":{"tests":[{"id":"379","name":"inviteReducer — invite-notify cap fanout broadcasts the INVITE line to invite-notify cap-enabled channel members"},{"id":"380","name":"inviteReducer — invite-notify cap fanout emits exactly one INVITE to the invitee via SendToNick, regardless of invite-notify"},{"id":"381","name":"inviteReducer — invite-notify cap fanout places the invite-notify broadcast AFTER the SendToNick to the invitee"},{"id":"382","name":"inviteReducer — invite-notify cap fanout does NOT emit an invite-notify broadcast when there are no other channel members"},{"id":"383","name":"inviteReducer — invite-notify cap fanout does not affect error paths (invitee already on channel)"},{"id":"384","name":"inviteReducer — invite-notify cap fanout does not affect the +i non-op rejection path"}],"source":"import { describe, expect, it } from 'vitest';\nimport { inviteReducer } from '../../src/commands/invite';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// inviteReducer — invite-notify cap fanout\n// ============================================================================\n\ndescribe('inviteReducer — invite-notify cap fanout', () => {\n it('broadcasts the INVITE line to invite-notify cap-enabled channel members', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n addMember(chan, 'c3', 'carol');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);\n\n // The 341 numeric and the SendToNick to the invitee come first; the\n // invite-notify broadcast is the trailing effect.\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast).toBeDefined();\n expect(broadcast?.cap).toBe('invite-notify');\n expect(broadcast?.lines).toEqual<"+"RawLine[]>([L(':alice!alice@example.com INVITE dave #foo')]);\n expect(broadcast?.capLines).toBeUndefined();\n });\n\n it('emits exactly one INVITE to the invitee via SendToNick, regardless of invite-notify', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob'); // invite-notify peer on-channel\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);\n\n const sendToNicks = out.effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'SendToNick' }> => e.tag === 'SendToNick',\n );\n expect(sendToNicks).toHaveLength(1);\n expect(sendToNicks[0]?.nick).toBe('dave');\n expect(sendToNicks[0]?.lines).toEqual<"+"RawLine[]>([\n L(':alice!alice@example.com INVITE dave #foo'),\n ]);\n });\n\n it('places the invite-notify broadcast AFTER the SendToNick to the invitee', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);\n\n const tags = out.effects.map((e) => e.tag);\n const sendToNickIdx = tags.indexOf('SendToNick');\n const broadcastIdx = tags.indexOf('Broadcast');\n expect(sendToNickIdx).toBeGreaterThanOrEqual(0);\n expect(broadcastIdx).toBeGreaterThan(sendToNickIdx);\n });\n\n it('does NOT emit an invite-notify broadcast when there are no other channel members', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n // No other members.\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);\n\n const broadcasts = out.effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n // The invite-notify broadcast is cap-only; with no peers to receive it\n // the reducer still emits a (no-op) cap-only broadcast so dispatch can\n // route per-recipient. The test asserts the cap is set correctly.\n expect(broadcasts).toHaveLength(1);\n expect(broadcasts[0]?.cap).toBe('invite-notify');\n });\n\n it('does not affect error paths (invitee already on channel)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n // 443 only; no SendToNick, no broadcast.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 443 alice bob #foo :is already on channel')]),\n ]);\n });\n\n it('does not affect the +i non-op rejection path', () => {\n const chan = makeChan('#foo');\n chan.modes.inviteOnly = true;\n addMember(chan, 'c1', 'alice', false);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n });\n});\n"},"tests/commands/invite.test.ts":{"tests":[{"id":"385","name":"inviteReducer — success emits 341 RPL_INVITING to the inviter and an INVITE notice to the invitee"},{"id":"386","name":"inviteReducer — success records the pending invite in channel state so the invitee can bypass +i"},{"id":"387","name":"inviteReducer — success allows inviting a nick that is not currently on the channel"},{"id":"388","name":"inviteReducer — success matches the invitee nick case-insensitively"},{"id":"389","name":"inviteReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"390","name":"inviteReducer — errors emits 461 ERR_NEEDMOREPARAMS when the channel argument is missing"},{"id":"391","name":"inviteReducer — errors emits 461 ERR_NEEDMOREPARAMS when both arguments are missing"},{"id":"392","name":"inviteReducer — errors emits 403 ERR_NOSUCHCHANNEL for an invalid channel name"},{"id":"393","name":"inviteReducer — errors emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"394","name":"inviteReducer — errors emits 442 ERR_NOTONCHANNEL when the inviter is not on the channel"},{"id":"395","name":"inviteReducer — errors emits 482 ERR_CHANOPRIVSNEEDED when a non-op invites to a +i channel"},{"id":"396","name":"inviteReducer — errors allows a non-op to invite to a non-invite-only channel"},{"id":"397","name":"inviteReducer — errors emits 443 ERR_USERONCHANNEL when the invitee is already on the channel"},{"id":"398","name":"inviteReducer — errors emits 443 ERR_USERONCHANNEL when checking by case-insensitive nick match"},{"id":"399","name":"inviteReducer — defensive uses * in error replies when the connection has no nick"},{"id":"400","name":"inviteReducer — defensive uses ? fallback for the source hostmask when nick is undefined"}],"source":"import { describe, expect, it } from 'vitest';\nimport { inviteReducer } from '../../src/commands/invite';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// inviteReducer — success\n// ============================================================================\n\ndescribe('inviteReducer — success', () => {\n it('emits 341 RPL_INVITING to the inviter and an INVITE notice to the invitee', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n // bob is offline (not on the channel) — the canonical invite case.\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),\n Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com INVITE bob #foo')],\n undefined,\n 'invite-notify',\n ),\n ]);\n });\n\n it('records the pending invite in channel state so the invitee can bypass +i', () => {\n const chan = makeChan('#foo');\n chan.modes.inviteOnly = true;\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(chan.pendingInvites.has('bob')).toBe(true);\n });\n\n it('allows inviting a nick that is not currently on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),\n Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com INVITE bob #foo')],\n undefined,\n 'invite-notify',\n ),\n ]);\n });\n\n it('matches the invitee nick case-insensitively', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['BOB', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 341 alice #foo BOB')]),\n Effect.sendToNick('BOB', 'c1', [L(':alice!alice@example.com INVITE BOB #foo')]),\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com INVITE BOB #foo')],\n undefined,\n 'invite-notify',\n ),\n ]);\n expect(chan.pendingInvites.has('bob')).toBe(true);\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(conn, clock);\n\n inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(7_700);\n });\n});\n\n// ============================================================================\n// inviteReducer — errors\n// ============================================================================\n\ndescribe('inviteReducer — errors', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when the channel argument is missing', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice INVITE :Not enough parameters')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when both arguments are missing', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice INVITE :Not enough parameters')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {\n const chan = makeChan('foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', 'foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', ''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when the inviter is not on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(\n chan,\n { command: 'INVITE', params: ['carol', '#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n });\n\n it('emits 482 ERR_CHANOPRIVSNEEDED when a non-op invites to a +i channel', () => {\n const chan = makeChan('#foo');\n chan.modes.inviteOnly = true;\n addMember(chan, 'c1', 'alice', false);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(\n chan,\n { command: 'INVITE', params: ['carol', '#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n expect(chan.pendingInvites.has('carol')).toBe(false);\n });\n\n it('allows a non-op to invite to a non-invite-only channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),\n Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com INVITE bob #foo')],\n undefined,\n 'invite-notify',\n ),\n ]);\n });\n\n it('emits 443 ERR_USERONCHANNEL when the invitee is already on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n // Already a member → 443, no 341, no INVITE.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 443 alice bob #foo :is already on channel')]),\n ]);\n expect(chan.pendingInvites.has('bob')).toBe(false);\n });\n\n it('emits 443 ERR_USERONCHANNEL when checking by case-insensitive nick match', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'Bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['BOB', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 443 alice BOB #foo :is already on channel')]),\n ]);\n });\n});\n\n// ============================================================================\n// inviteReducer — defensive paths\n// ============================================================================\n\ndescribe('inviteReducer — defensive', () => {\n it('uses * in error replies when the connection has no nick', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * INVITE :Not enough parameters')]),\n ]);\n });\n\n it('uses ? fallback for the source hostmask when nick is undefined', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 341 * #foo bob')]),\n Effect.sendToNick('bob', 'c1', [L(':? INVITE bob #foo')]),\n Effect.broadcast('#foo', [L(':? INVITE bob #foo')], undefined, 'invite-notify'),\n ]);\n });\n});\n"},"tests/commands/ison.test.ts":{"tests":[{"id":"401","name":"isonReducer emits 303 RPL_ISON with the online nicks from the requested set"},{"id":"402","name":"isonReducer only includes nicks that are online"},{"id":"403","name":"isonReducer emits an empty trailing when no requested nicks are online"},{"id":"404","name":"isonReducer preserves the registered display spelling from the online map"},{"id":"405","name":"isonReducer matches case-insensitively (rfc1459 case-mapping)"},{"id":"406","name":"isonReducer reports the registered spelling even when requested with different case"},{"id":"407","name":"isonReducer uses * as nick placeholder for unregistered connections"},{"id":"408","name":"isonReducer updates lastSeen to ctx.clock.now()"},{"id":"409","name":"isonReducer returns the same state reference"},{"id":"410","name":"isonReducer handles no-nick argument (empty request)"},{"id":"411","name":"isonReducer skips empty-string params in the nick list"}],"source":"import { describe, expect, it } from 'vitest';\nimport { isonReducer } from '../../src/commands/ison';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(state: ConnectionState): Ctx {\n return buildCtx({\n serverConfig: baseServerConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst ison = (...nicks: string[]) => ({ command: 'ISON', params: nicks, tags: {} }) as const;\n\n/** Builds an online map from display-case nicks (auto-folds the key). */\nconst onlineMap = (...nicks: string[]): Map<"+"string, string> =>\n new Map(nicks.map((n) => [n.toLowerCase(), n]));\n\ndescribe('isonReducer', () => {\n it('emits 303 RPL_ISON with the online nicks from the requested set', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob', 'carol'), ison('bob', 'carol', 'dave'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :bob carol')]),\n ]);\n });\n\n it('only includes nicks that are online', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob'), ison('bob', 'carol', 'dave'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),\n ]);\n });\n\n it('emits an empty trailing when no requested nicks are online', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(new Map(), ison('nobody'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :')]),\n ]);\n });\n\n it('preserves the registered display spelling from the online map', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('Bob'), ison('Bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),\n ]);\n });\n\n it('matches case-insensitively (rfc1459 case-mapping)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob'), ison('BOB'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),\n ]);\n });\n\n it('reports the registered spelling even when requested with different case', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('Bob'), ison('BOB'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),\n ]);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob'), ison('bob'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 303 * :bob')]),\n );\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n isonReducer(new Map(), ison('bob'), ctx);\n\n expect(state.lastSeen).toBe(5_000);\n });\n\n it('returns the same state reference', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(new Map(), ison(), ctx);\n\n expect(out.state).toBe(state);\n });\n\n it('handles no-nick argument (empty request)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob'), ison(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :')]),\n ]);\n });\n\n it('skips empty-string params in the nick list', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = isonReducer(onlineMap('bob'), ison('', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),\n ]);\n });\n});\n"},"tests/commands/isupport.test.ts":{"tests":[{"id":"412","name":"generateIsupport — token set includes the canonical RFC 2812/IRCv3 base tokens"},{"id":"413","name":"generateIsupport — token set ends the final line with the `:are supported by this server` trailer"},{"id":"414","name":"generateIsupport — token set produces at least one 005 line"},{"id":"415","name":"generateIsupport — token set derives token values from serverConfig rather than hard-coding"},{"id":"416","name":"generateIsupport — token values emits CHANTYPES=#"},{"id":"417","name":"generateIsupport — token values emits PREFIX=(ov)@+"},{"id":"418","name":"generateIsupport — token values emits CASEMAPPING=rfc1459"},{"id":"419","name":"generateIsupport — token values emits CHANMODES with the four comma-separated mode classes"},{"id":"420","name":"generateIsupport — token values emits MODES=4 (max mode changes per command)"},{"id":"421","name":"generateIsupport — token values emits MAXCHANNELS from serverConfig.maxChannelsPerUser"},{"id":"422","name":"generateIsupport — token values emits MAXTARGETS from serverConfig.maxTargetsPerCommand"},{"id":"423","name":"generateIsupport — token values advertises the bare SAFELIST token (LIST is non-destructive)"},{"id":"424","name":"generateIsupport — token values advertises SAFELIST regardless of negotiated caps (ISUPPORT is global)"},{"id":"425","name":"generateIsupport — token values advertises MONITOR=<"+"n> with the default monitor cap ceiling"},{"id":"426","name":"generateIsupport — line splitting splits tokens across multiple 005 lines when the total would exceed 510 bytes"},{"id":"427","name":"generateIsupport — line splitting places all tokens before the trailing `:are supported by this server`"},{"id":"428","name":"generateIsupport — line splitting labels every line with the 005 numeric and the connection nick"},{"id":"429","name":"generateIsupport — line splitting uses * as the nick when no nick is provided"},{"id":"430","name":"generateIsupport — capability-aware tokens does not advertise BOT when the client lacks the bot-mode cap"},{"id":"431","name":"generateIsupport — capability-aware tokens returns an empty array when called with no config (defensive)"},{"id":"432","name":"generateIsupport — MONITOR token (config-derived) derives MONITOR=<"+"n> from serverConfig.monitorLimit when set"},{"id":"433","name":"generateIsupport — MONITOR token (config-derived) falls back to the default monitor ceiling when monitorLimit is unset"},{"id":"434","name":"generateIsupport — STATUSMSG token advertises STATUSMSG=@+ (status-msg prefixes mirror PREFIX)"},{"id":"435","name":"generateIsupport — EXTBAN token (config-derived prefix) advertises EXTBAN=<"+"prefix>,q with the default extban prefix"},{"id":"436","name":"generateIsupport — EXTBAN token (config-derived prefix) derives the EXTBAN prefix from serverConfig.extbanPrefix"},{"id":"437","name":"generateIsupport — ACCOUNTEXTBAN token (services-gated) omits ACCOUNTEXTBAN when services are not enabled"},{"id":"438","name":"generateIsupport — ACCOUNTEXTBAN token (services-gated) advertises ACCOUNTEXTBAN=a when services are enabled"},{"id":"439","name":"generateIsupport — draft cap advertisement tokens advertises the TYPING token (draft/typing is supported)"},{"id":"440","name":"generateIsupport — draft cap advertisement tokens derives MULTILINE=<"+"n> from serverConfig.multilineMaxBytes (draft/multiline)"},{"id":"441","name":"generateIsupport — draft cap advertisement tokens falls back to the default multiline byte budget when multilineMaxBytes is unset"}],"source":"import { describe, expect, it } from 'vitest';\nimport { generateIsupport } from '../../src/commands/isupport';\nimport type { ServerConfig } from '../../src/types';\n\nfunction makeConfig(overrides: Partial<"+"ServerConfig> = {}): ServerConfig {\n return {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n ...overrides,\n };\n}\n\n// ============================================================================\n// generateIsupport — token set\n// ============================================================================\n\ndescribe('generateIsupport — token set', () => {\n it('includes the canonical RFC 2812/IRCv3 base tokens', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n\n expect(all.some((t) => t.startsWith('CHANTYPES='))).toBe(true);\n expect(all.some((t) => t.startsWith('CHANMODES='))).toBe(true);\n expect(all.some((t) => t.startsWith('PREFIX='))).toBe(true);\n expect(all.some((t) => t.startsWith('MODES='))).toBe(true);\n expect(all.some((t) => t.startsWith('NICKLEN='))).toBe(true);\n expect(all.some((t) => t.startsWith('CHANNELLEN='))).toBe(true);\n expect(all.some((t) => t.startsWith('TOPICLEN='))).toBe(true);\n expect(all.some((t) => t.startsWith('CASEMAPPING='))).toBe(true);\n expect(all.some((t) => t.startsWith('NETWORK='))).toBe(true);\n expect(all.some((t) => t.startsWith('MAXCHANNELS='))).toBe(true);\n expect(all.some((t) => t.startsWith('MAXTARGETS='))).toBe(true);\n });\n\n it('ends the final line with the `:are supported by this server` trailer', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const last = lines.at(-1);\n expect(last?.text.endsWith(':are supported by this server')).toBe(true);\n });\n\n it('produces at least one 005 line', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n expect(lines.length).toBeGreaterThanOrEqual(1);\n expect(lines[0]?.text).toMatch(/ 005 /u);\n });\n\n it('derives token values from serverConfig rather than hard-coding', () => {\n const cfg = makeConfig({ nickLen: 15, channelLen: 64, topicLen: 300, networkName: 'Zeta' });\n const lines = generateIsupport(cfg, new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n\n expect(all).toContain('NICKLEN=15');\n expect(all).toContain('CHANNELLEN=64');\n expect(all).toContain('TOPICLEN=300');\n expect(all).toContain('NETWORK=Zeta');\n });\n});\n\n// ============================================================================\n// generateIsupport — token values\n// ============================================================================\n\ndescribe('generateIsupport — token values', () => {\n it('emits CHANTYPES=#', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('CHANTYPES=#');\n });\n\n it('emits PREFIX=(ov)@+', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('PREFIX=(ov)@+');\n });\n\n it('emits CASEMAPPING=rfc1459', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('CASEMAPPING=rfc1459');\n });\n\n it('emits CHANMODES with the four comma-separated mode classes', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n const cm = all.find((t) => t.startsWith('CHANMODES='));\n expect(cm).toBeDefined();\n // Class A (list), B (param-set), C (param-always), D (boolean).\n expect(cm?.split('=').at(1)?.split(',').length).toBe(4);\n // Our server supports: list=b, key=k, limit=l, bool=imnpst.\n expect(cm).toContain('b');\n expect(cm).toContain('k');\n expect(cm).toContain('l');\n expect(cm).toContain('i');\n expect(cm).toContain('t');\n expect(cm).toContain('n');\n expect(cm).toContain('m');\n expect(cm).toContain('s');\n expect(cm).toContain('p');\n });\n\n it('emits MODES=4 (max mode changes per command)', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MODES=4');\n });\n\n it('emits MAXCHANNELS from serverConfig.maxChannelsPerUser', () => {\n const lines = generateIsupport(makeConfig({ maxChannelsPerUser: 20 }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MAXCHANNELS=20');\n });\n\n it('emits MAXTARGETS from serverConfig.maxTargetsPerCommand', () => {\n const lines = generateIsupport(makeConfig({ maxTargetsPerCommand: 4 }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MAXTARGETS=4');\n });\n\n it('advertises the bare SAFELIST token (LIST is non-destructive)', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('SAFELIST');\n // SAFELIST is a boolean token (no `=` value).\n expect(all.some((t) => t.startsWith('SAFELIST='))).toBe(false);\n });\n\n it('advertises SAFELIST regardless of negotiated caps (ISUPPORT is global)', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>(['safelist']));\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('SAFELIST');\n\n const linesNoCap = generateIsupport(makeConfig(), new Set<"+"string>());\n const allNoCap = linesNoCap.flatMap((l) => l.text.split(' '));\n expect(allNoCap).toContain('SAFELIST');\n });\n\n it('advertises MONITOR=<"+"n> with the default monitor cap ceiling', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MONITOR=30');\n });\n});\n\n// ============================================================================\n// generateIsupport — line splitting\n// ============================================================================\n\ndescribe('generateIsupport — line splitting', () => {\n it('splits tokens across multiple 005 lines when the total would exceed 510 bytes', () => {\n // The realistic trigger is a long NETWORK name: NETWORK=<"+"long> is one\n // of the longer tokens, and packing it with the rest would exceed 510.\n // We use a name that pushes the total past the ceiling but keeps every\n // individual token short enough to fit on its own line.\n const longName = `${'Z'.repeat(450)}`;\n const lines = generateIsupport(makeConfig({ networkName: longName }), new Set<"+"string>());\n\n expect(lines.length).toBeGreaterThan(1);\n for (const line of lines) {\n // Each wire line is bounded by 510 bytes (incl. trailing \\r\\n).\n expect(line.text.length).toBeLessThanOrEqual(510);\n }\n // Only the last line has the trailer.\n const trailers = lines.filter((l) => l.text.endsWith(':are supported by this server'));\n expect(trailers.length).toBe(1);\n });\n\n it('places all tokens before the trailing `:are supported by this server`', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const last = lines.at(-1)?.text ?? '';\n // Strip the leading `:<"+"server> 005 <"+"nick> ` prefix, then check the\n // token region (between prefix and trailer) contains no stray colons.\n const withoutPrefix = last.replace(/^:\\S+ 005 \\S+ /u, '');\n const beforeTrailer = withoutPrefix.split(':are supported by this server')[0];\n expect(beforeTrailer).not.toContain(':');\n });\n\n it('labels every line with the 005 numeric and the connection nick', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>(), 'alice');\n for (const line of lines) {\n expect(line.text).toMatch(/ 005 alice /u);\n }\n });\n\n it('uses * as the nick when no nick is provided', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n for (const line of lines) {\n expect(line.text).toMatch(/ 005 \\* /u);\n }\n });\n});\n\n// ============================================================================\n// generateIsupport — IRCv3 capability-aware tokens\n// ============================================================================\n\ndescribe('generateIsupport — capability-aware tokens', () => {\n it('does not advertise BOT when the client lacks the bot-mode cap', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all.some((t) => t.startsWith('BOT='))).toBe(false);\n });\n\n it('returns an empty array when called with no config (defensive)', () => {\n // Sanity: contract is \"always returns at least one line for valid config\".\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n expect(lines.length).toBeGreaterThan(0);\n });\n});\n\n// ============================================================================\n// generateIsupport — cap/mode-derived ISUPPORT tokens\n// ============================================================================\n\ndescribe('generateIsupport — MONITOR token (config-derived)', () => {\n it('derives MONITOR=<"+"n> from serverConfig.monitorLimit when set', () => {\n const lines = generateIsupport(makeConfig({ monitorLimit: 50 }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MONITOR=50');\n });\n\n it('falls back to the default monitor ceiling when monitorLimit is unset', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MONITOR=30');\n });\n});\n\ndescribe('generateIsupport — STATUSMSG token', () => {\n it('advertises STATUSMSG=@+ (status-msg prefixes mirror PREFIX)', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('STATUSMSG=@+');\n });\n});\n\ndescribe('generateIsupport — EXTBAN token (config-derived prefix)', () => {\n it('advertises EXTBAN=<"+"prefix>,q with the default extban prefix', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('EXTBAN=~,q');\n });\n\n it('derives the EXTBAN prefix from serverConfig.extbanPrefix', () => {\n const lines = generateIsupport(makeConfig({ extbanPrefix: '$' }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('EXTBAN=$,q');\n });\n});\n\ndescribe('generateIsupport — ACCOUNTEXTBAN token (services-gated)', () => {\n it('omits ACCOUNTEXTBAN when services are not enabled', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all.some((t) => t.startsWith('ACCOUNTEXTBAN'))).toBe(false);\n });\n\n it('advertises ACCOUNTEXTBAN=a when services are enabled', () => {\n const lines = generateIsupport(makeConfig({ servicesEnabled: true }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('ACCOUNTEXTBAN=a');\n });\n});\n\ndescribe('generateIsupport — draft cap advertisement tokens', () => {\n it('advertises the TYPING token (draft/typing is supported)', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('TYPING');\n });\n\n it('derives MULTILINE=<"+"n> from serverConfig.multilineMaxBytes (draft/multiline)', () => {\n const lines = generateIsupport(makeConfig({ multilineMaxBytes: 8192 }), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MULTILINE=8192');\n });\n\n it('falls back to the default multiline byte budget when multilineMaxBytes is unset', () => {\n const lines = generateIsupport(makeConfig(), new Set<"+"string>());\n const all = lines.flatMap((l) => l.text.split(' '));\n expect(all).toContain('MULTILINE=4096');\n });\n});\n"},"tests/commands/join.test.ts":{"tests":[{"id":"442","name":"isValidChannelName accepts a # channel"},{"id":"443","name":"isValidChannelName accepts a & channel"},{"id":"444","name":"isValidChannelName accepts a single-char name after the prefix"},{"id":"445","name":"isValidChannelName rejects an empty string"},{"id":"446","name":"isValidChannelName rejects a name without a channel prefix"},{"id":"447","name":"isValidChannelName rejects a name starting with +"},{"id":"448","name":"isValidChannelName rejects a name containing a space"},{"id":"449","name":"isValidChannelName rejects a name containing a comma"},{"id":"450","name":"isValidChannelName rejects a name containing a colon"},{"id":"451","name":"isValidChannelName rejects a name exceeding the length cap"},{"id":"452","name":"isValidChannelName accepts a name at exactly the length cap"},{"id":"453","name":"joinReducer — success broadcasts JOIN to the channel including the joiner and sends 353/366 to the joiner"},{"id":"454","name":"joinReducer — success adds the joiner to the channel roster as a regular member when others are present"},{"id":"455","name":"joinReducer — success grants op to the first user to join a channel"},{"id":"456","name":"joinReducer — success adds the channel to the connection joinedChannels set (cross-authority mutation)"},{"id":"457","name":"joinReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"458","name":"joinReducer — success lists existing members with their prefixes in the 353 NAMES reply"},{"id":"459","name":"joinReducer — success uses * as the secret-channel sigil in 353 when +s is set"},{"id":"460","name":"joinReducer — success uses @ as the private-channel sigil in 353 when +p is set"},{"id":"461","name":"joinReducer — success accepts the correct key for a +k channel"},{"id":"462","name":"joinReducer — success proceeds with the join when the ban list is non-empty but no mask matches"},{"id":"463","name":"joinReducer — success allows an invited user to bypass +i"},{"id":"464","name":"joinReducer — success includes the joiner in the broadcast (no except)"},{"id":"465","name":"joinReducer — success returns the same state reference (mutation permitted, no copy)"},{"id":"466","name":"joinReducer — success builds the JOIN source from the connection hostmask without @host when host is absent"},{"id":"467","name":"joinReducer — success falls back to ? and * placeholders when the connection has no nick"},{"id":"468","name":"joinReducer — rejections emits 461 ERR_NEEDMOREPARAMS when no channel is supplied"},{"id":"469","name":"joinReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix"},{"id":"470","name":"joinReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma"},{"id":"471","name":"joinReducer — rejections emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit"},{"id":"472","name":"joinReducer — rejections does not count a channel the user is already on against the max-channels limit"},{"id":"473","name":"joinReducer — rejections emits 474 ERR_BANNEDFROMCHAN when a ban mask matches the joiner hostmask"},{"id":"474","name":"joinReducer — rejections honors the ? wildcard in ban masks when checking the joiner hostmask"},{"id":"475","name":"joinReducer — rejections emits 473 ERR_INVITEONLYCHAN when +i is set and the connection has no pending invite"},{"id":"476","name":"joinReducer — rejections emits 471 ERR_CHANNELISFULL when +l is set and the roster is at the limit"},{"id":"477","name":"joinReducer — rejections emits 475 ERR_BADCHANNELKEY when +k is set and no key was supplied"},{"id":"478","name":"joinReducer — rejections emits 475 ERR_BADCHANNELKEY when +k is set and the supplied key is wrong"},{"id":"479","name":"joinReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"},{"id":"480","name":"joinReducer — already on channel is a silent no-op when the joiner is already on the channel"},{"id":"481","name":"handleJoinZero emits a PART broadcast and roster-removal delta for every joined channel"},{"id":"482","name":"handleJoinZero clears the connection joinedChannels set"},{"id":"483","name":"handleJoinZero emits no effects when the connection has joined no channels"},{"id":"484","name":"joinReducer — extended-join emits a cap-split broadcast with the extended JOIN for cap-enabled peers"},{"id":"485","name":"joinReducer — extended-join uses the authenticated account name in the extended JOIN when set"},{"id":"486","name":"joinReducer — extended-join uses _ as the account when the joiner is not authenticated"},{"id":"487","name":"joinReducer — extended-join carries the legacy JOIN line for non-cap peers"},{"id":"488","name":"joinReducer — extended-join includes the joiner in the broadcast (no except)"},{"id":"489","name":"joinReducer — extended-join falls back to a legacy-only broadcast when realname is absent (defensive)"},{"id":"490","name":"joinReducer — chathistory auto-playback prepends a chathistory BATCH before the JOIN/353/366 lines for a cap-enabled joiner"},{"id":"491","name":"joinReducer — chathistory auto-playback advances the last-read marker to the newest replayed msgid"},{"id":"492","name":"joinReducer — chathistory auto-playback does NOT emit playback when the store is empty"},{"id":"493","name":"joinReducer — chathistory auto-playback does NOT emit playback when the connection lacks the chathistory cap"},{"id":"494","name":"joinReducer — chathistory auto-playback does NOT emit playback when no MessageStore is bound"},{"id":"495","name":"joinReducer — chathistory auto-playback replays nothing on re-JOIN when no new messages arrived (marker unchanged)"},{"id":"496","name":"joinReducer — chathistory auto-playback replays exactly the K new messages on re-JOIN after K new messages"},{"id":"497","name":"joinReducer — chathistory auto-playback respects chatHistoryPlaybackLimit from ServerConfig"}],"source":"import { describe, expect, it } from 'vitest';\nimport { handleJoinZero, isValidChannelName, joinReducer } from '../../src/commands/join';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryMessageStore,\n type MessageStore,\n SequentialIdFactory,\n type StoredMessage,\n} from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n conn: ConnectionState,\n clock = new FakeClock(1_000),\n messages?: MessageStore,\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n ...(messages !== undefined ? { messages } : {}),\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n/** Adds a non-op member to the channel for setups that need a pre-populated roster. */\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\n// ============================================================================\n// isValidChannelName\n// ============================================================================\n\ndescribe('isValidChannelName', () => {\n it('accepts a # channel', () => {\n expect(isValidChannelName('#foo', 50)).toBe(true);\n });\n\n it('accepts a & channel', () => {\n expect(isValidChannelName('&foo', 50)).toBe(true);\n });\n\n it('accepts a single-char name after the prefix', () => {\n expect(isValidChannelName('#a', 50)).toBe(true);\n });\n\n it('rejects an empty string', () => {\n expect(isValidChannelName('', 50)).toBe(false);\n });\n\n it('rejects a name without a channel prefix', () => {\n expect(isValidChannelName('foo', 50)).toBe(false);\n });\n\n it('rejects a name starting with +', () => {\n expect(isValidChannelName('+foo', 50)).toBe(false);\n });\n\n it('rejects a name containing a space', () => {\n expect(isValidChannelName('#foo bar', 50)).toBe(false);\n });\n\n it('rejects a name containing a comma', () => {\n expect(isValidChannelName('#foo,bar', 50)).toBe(false);\n });\n\n it('rejects a name containing a colon', () => {\n expect(isValidChannelName('#foo:bar', 50)).toBe(false);\n });\n\n it('rejects a name exceeding the length cap', () => {\n expect(isValidChannelName(`#${'a'.repeat(50)}`, 50)).toBe(false);\n });\n\n it('accepts a name at exactly the length cap', () => {\n expect(isValidChannelName(`#${'a'.repeat(49)}`, 50)).toBe(true);\n });\n});\n\n// ============================================================================\n// joinReducer — success path\n// ============================================================================\n\ndescribe('joinReducer — success', () => {\n it('broadcasts JOIN to the channel including the joiner and sends 353/366 to the joiner', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],\n }),\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com JOIN #foo')],\n undefined,\n 'extended-join',\n [L(':alice!alice@example.com JOIN #foo _ :Alice')],\n ),\n Effect.send('c1', [\n L(':irc.example.com 353 alice = #foo :@alice'),\n L(':irc.example.com 366 alice #foo :End of /NAMES list.'),\n ]),\n ]);\n });\n\n it('adds the joiner to the channel roster as a regular member when others are present', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const entry = out.state.members.get('c1');\n expect(entry).toBeDefined();\n if (entry !== undefined) {\n expect(entry.nick).toBe('alice');\n expect(entry.op).toBe(false);\n expect(entry.voice).toBe(false);\n }\n });\n\n it('grants op to the first user to join a channel', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const entry = out.state.members.get('c1');\n expect(entry?.op).toBe(true);\n });\n\n it('adds the channel to the connection joinedChannels set (cross-authority mutation)', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.joinedChannels.has('#foo')).toBe(true);\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const clock = new FakeClock(9_000);\n const ctx = makeCtx(conn, clock);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n expect(conn.lastSeen).toBe(9_000);\n });\n\n it('lists existing members with their prefixes in the 353 NAMES reply', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob', true);\n addMember(chan, 'c3', 'carol');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // bob is op (@), carol is a regular member. alice is added by this join and\n // is NOT op (she is not first). Names list is one space-separated trailing\n // param; we assert the substring membership rather than exact ordering since\n // roster iteration order matches insertion.\n const sendEffect = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n expect(sendEffect).toBeDefined();\n const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));\n expect(namesLine).toBeDefined();\n expect(namesLine?.text).toContain('@bob');\n expect(namesLine?.text).toContain('carol');\n expect(namesLine?.text).toContain('alice');\n });\n\n it('uses * as the secret-channel sigil in 353 when +s is set', () => {\n const chan = makeChan('#foo');\n chan.modes.secret = true;\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const sendEffect = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));\n expect(namesLine?.text).toContain('353 alice * #foo');\n });\n\n it('uses @ as the private-channel sigil in 353 when +p is set', () => {\n const chan = makeChan('#foo');\n chan.modes.private = true;\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const sendEffect = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n const namesLine = sendEffect?.lines.find((l) => l.text.startsWith(':irc.example.com 353'));\n expect(namesLine?.text).toContain('353 alice @ #foo');\n });\n\n it('accepts the correct key for a +k channel', () => {\n const chan = makeChan('#foo');\n chan.modes.key = 'secret';\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo', 'secret'], tags: {} }, ctx);\n\n expect(out.state.members.has('c1')).toBe(true);\n });\n\n it('proceeds with the join when the ban list is non-empty but no mask matches', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@baddomain.example');\n chan.banMasks.add('evil!*@*');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state.members.has('c1')).toBe(true);\n const sendEffect = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n expect(sendEffect).toBeDefined();\n });\n\n it('allows an invited user to bypass +i', () => {\n const chan = makeChan('#foo');\n chan.modes.inviteOnly = true;\n // Pending invites are keyed by lowercased nick.\n chan.pendingInvites.add('alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state.members.has('c1')).toBe(true);\n // invite is consumed\n expect(out.state.pendingInvites.has('alice')).toBe(false);\n });\n\n it('includes the joiner in the broadcast (no except)', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.except).toBeUndefined();\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n });\n\n it('builds the JOIN source from the connection hostmask without @host when host is absent', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.\n delete conn.host;\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice JOIN #foo')], undefined, 'extended-join', [\n L(':alice!alice JOIN #foo _ :Alice'),\n ]),\n );\n });\n\n it('falls back to ? and * placeholders when the connection has no nick', () => {\n const chan = makeChan('#foo');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // Roster entry uses '?' since no nick is available.\n expect(out.state.members.get('c1')?.nick).toBe('?');\n // JOIN broadcast source is '?'.\n expect(out.effects).toContainEqual<"+"EffectType>(Effect.broadcast('#foo', [L(':? JOIN #foo')]));\n // 353/366 use '*' as the numeric target. The joiner is first, so it is\n // also an op and appears as `@?` in the names list.\n const sendEffect = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n expect(sendEffect?.lines[0]?.text).toBe(':irc.example.com 353 * = #foo :@?');\n expect(sendEffect?.lines[1]?.text).toBe(':irc.example.com 366 * #foo :End of /NAMES list.');\n });\n});\n\n// ============================================================================\n// joinReducer — rejections\n// ============================================================================\n\ndescribe('joinReducer — rejections', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice JOIN :Not enough parameters')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {\n const chan = makeChan('foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo,bar'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),\n ]);\n });\n\n it('emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit', () => {\n const chan = makeChan('#newchan');\n const conn = makeConn();\n for (let i = 0; i <"+" serverConfig.maxChannelsPerUser; i++) {\n conn.joinedChannels.add(`#c${i}`);\n }\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#newchan'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 405 alice #newchan :You have joined too many channels'),\n ]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('does not count a channel the user is already on against the max-channels limit', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n for (let i = 0; i <"+" serverConfig.maxChannelsPerUser; i++) {\n conn.joinedChannels.add(`#c${i}`);\n }\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // No error: the user is already on #foo, so this is a silent no-op.\n expect(out.effects).toEqual([]);\n });\n\n it('emits 474 ERR_BANNEDFROMCHAN when a ban mask matches the joiner hostmask', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@example.com');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 474 alice #foo :Cannot join channel (+b)')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('honors the ? wildcard in ban masks when checking the joiner hostmask', () => {\n const chan = makeChan('#foo');\n // `?` matches exactly one char; five `?` match the 5-char user segment\n // `alice` between `!` and `@` in alice!alice@example.com.\n chan.banMasks.add('alice!?????@example.com');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 474 alice #foo :Cannot join channel (+b)')]),\n ]);\n });\n\n it('emits 473 ERR_INVITEONLYCHAN when +i is set and the connection has no pending invite', () => {\n const chan = makeChan('#foo');\n chan.modes.inviteOnly = true;\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 473 alice #foo :Cannot join channel (+i)')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('emits 471 ERR_CHANNELISFULL when +l is set and the roster is at the limit', () => {\n const chan = makeChan('#foo');\n chan.modes.limit = 1;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 471 alice #foo :Cannot join channel (+l)')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('emits 475 ERR_BADCHANNELKEY when +k is set and no key was supplied', () => {\n const chan = makeChan('#foo');\n chan.modes.key = 'secret';\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 475 alice #foo :Cannot join channel (+k)')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('emits 475 ERR_BADCHANNELKEY when +k is set and the supplied key is wrong', () => {\n const chan = makeChan('#foo');\n chan.modes.key = 'secret';\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo', 'wrong'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 475 alice #foo :Cannot join channel (+k)')]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('badname');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['badname'], tags: {} }, ctx);\n\n // 403 path fires numericErr, which substitutes `*` for the absent nick.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 * badname :No such channel')]),\n ]);\n });\n});\n\n// ============================================================================\n// joinReducer — already on channel\n// ============================================================================\n\ndescribe('joinReducer — already on channel', () => {\n it('is a silent no-op when the joiner is already on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual([]);\n expect(out.state.members.size).toBe(1);\n });\n});\n\n// ============================================================================\n// handleJoinZero\n// ============================================================================\n\ndescribe('handleJoinZero', () => {\n it('emits a PART broadcast and roster-removal delta for every joined channel', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n conn.joinedChannels.add('#bar');\n const ctx = makeCtx(conn);\n\n const effects = handleJoinZero(ctx);\n\n expect(effects).toEqual<"+"EffectType[]>(\n expect.arrayContaining([\n Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),\n Effect.broadcast('#bar', [L(':alice!alice@example.com PART #bar')]),\n ]),\n );\n // One roster-removal delta per channel.\n const deltas = effects.filter(\n (e): e is Extract<"+"EffectType, { tag: 'ApplyChannelDelta' }> => e.tag === 'ApplyChannelDelta',\n );\n expect(deltas).toHaveLength(2);\n });\n\n it('clears the connection joinedChannels set', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n handleJoinZero(ctx);\n\n expect(conn.joinedChannels.size).toBe(0);\n });\n\n it('emits no effects when the connection has joined no channels', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const effects = handleJoinZero(ctx);\n\n expect(effects).toEqual([]);\n });\n});\n\n// ============================================================================\n// joinReducer — IRCv3 extended-join\n// (https://ircv3.net/specs/extensions/extended-join-3.1.html)\n//\n// Peers that negotiated `extended-join` see the account + realname in the\n// JOIN line; legacy peers see the bare channel. The reducer cannot see each\n// recipient's caps, so it emits one cap-split Broadcast: `lines` (legacy) for\n// non-cap members and `capLines` (extended) for cap-enabled members. The\n// dispatch layer resolves per-recipient.\n// ============================================================================\n\ndescribe('joinReducer — extended-join', () => {\n it('emits a cap-split broadcast with the extended JOIN for cap-enabled peers', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // Extended format: :nick!user@host JOIN #chan account :realname.\n // Account is `_` (not logged in); realname from USER.\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com JOIN #foo')],\n undefined,\n 'extended-join',\n [L(':alice!alice@example.com JOIN #foo _ :Alice')],\n ),\n );\n });\n\n it('uses the authenticated account name in the extended JOIN when set', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n conn.account = 'alice-account';\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const extended = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> =>\n e.tag === 'Broadcast' && e.cap === 'extended-join',\n );\n expect(extended?.capLines).toEqual([\n L(':alice!alice@example.com JOIN #foo alice-account :Alice'),\n ]);\n });\n\n it('uses _ as the account when the joiner is not authenticated', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const extended = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> =>\n e.tag === 'Broadcast' && e.cap === 'extended-join',\n );\n expect(extended?.capLines?.[0]?.text).toBe(':alice!alice@example.com JOIN #foo _ :Alice');\n });\n\n it('carries the legacy JOIN line for non-cap peers', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.lines).toEqual([L(':alice!alice@example.com JOIN #foo')]);\n });\n\n it('includes the joiner in the broadcast (no except)', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n conn.caps.add('extended-join');\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.except).toBeUndefined();\n });\n\n it('falls back to a legacy-only broadcast when realname is absent (defensive)', () => {\n const chan = makeChan('#foo');\n // Bare connection: no nick, user, host, or realname.\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // No cap-split: plain legacy broadcast with no cap/capLines fields.\n expect(out.effects).toContainEqual<"+"EffectType>(Effect.broadcast('#foo', [L(':? JOIN #foo')]));\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.cap).toBeUndefined();\n expect(broadcast?.capLines).toBeUndefined();\n });\n});\n\n// ============================================================================\n// joinReducer — chathistory auto-playback\n// ============================================================================\n\n/** Connection negotiated draft/chathistory (+ the deps it implies). */\nfunction makeCapConn(id = 'c1', nick = 'alice'): ConnectionState {\n const conn = makeConn(id, nick);\n conn.caps.add('draft/chathistory');\n conn.caps.add('batch');\n conn.caps.add('server-time');\n conn.caps.add('message-tags');\n return conn;\n}\n\n/** Records N messages into the store as if spoken by `bob`. */\nfunction seedHistory(store: MessageStore, chan: string, count: number): StoredMessage[] {\n const out: StoredMessage[] = [];\n for (let i = 1; i <"+"= count; i++) {\n const m: StoredMessage = {\n msgid: `m${i}`,\n time: i * 1_000,\n chan: chan.toLowerCase(),\n command: 'PRIVMSG',\n nick: 'bob',\n user: 'bob',\n host: 'ex.org',\n text: `msg ${i}`,\n };\n store.record(m);\n out.push(m);\n }\n return out;\n}\n\ndescribe('joinReducer — chathistory auto-playback', () => {\n it('prepends a chathistory BATCH before the JOIN/353/366 lines for a cap-enabled joiner', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n const seeded = seedHistory(store, '#foo', 2);\n const conn = makeCapConn();\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // The first client-visible line must be the BATCH +start marker.\n const firstSend = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> => e.tag === 'Send',\n );\n const firstLine = firstSend?.lines[0]?.text;\n expect(firstLine).toBe('BATCH +batch-0 chathistory #foo');\n\n // The chathistory batch body carries the two replayed messages.\n expect(firstSend?.lines.map((l) => l.text)).toEqual([\n 'BATCH +batch-0 chathistory #foo',\n `@time=1970-01-01T00:00:01.000Z;msgid=${seeded[0]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 1`,\n `@time=1970-01-01T00:00:02.000Z;msgid=${seeded[1]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 2`,\n 'BATCH -batch-0',\n ]);\n\n // The JOIN broadcast and NAMES follow the playback Send.\n const tags = out.effects.map((e) => e.tag);\n const sendIdx = tags.indexOf('Send');\n const broadcastIdx = tags.indexOf('Broadcast');\n expect(sendIdx).toBeLessThan(broadcastIdx);\n expect(broadcastIdx).toBeGreaterThan(-1);\n });\n\n it('advances the last-read marker to the newest replayed msgid', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n seedHistory(store, '#foo', 3);\n const conn = makeCapConn();\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m3');\n });\n\n it('does NOT emit playback when the store is empty', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n const conn = makeCapConn();\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n // No Send carrying a BATCH chathistory start line.\n const hasPlayback = out.effects.some(\n (e) =>\n e.tag === 'Send' &&\n e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),\n );\n expect(hasPlayback).toBe(false);\n });\n\n it('does NOT emit playback when the connection lacks the chathistory cap', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n seedHistory(store, '#foo', 3);\n const conn = makeConn(); // no chathistory cap\n conn.caps.add('batch');\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const hasPlayback = out.effects.some(\n (e) =>\n e.tag === 'Send' &&\n e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),\n );\n expect(hasPlayback).toBe(false);\n });\n\n it('does NOT emit playback when no MessageStore is bound', () => {\n const chan = makeChan('#foo');\n const conn = makeCapConn();\n const ctx = makeCtx(conn); // no store\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n\n const hasPlayback = out.effects.some(\n (e) =>\n e.tag === 'Send' &&\n e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),\n );\n expect(hasPlayback).toBe(false);\n });\n\n it('replays nothing on re-JOIN when no new messages arrived (marker unchanged)', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n seedHistory(store, '#foo', 2);\n const conn = makeCapConn();\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n // First join: replays both, marker → m2.\n joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');\n\n // Simulate PART (roster + joinedChannels cleared; marker persists).\n chan.members.delete('c1');\n conn.joinedChannels.delete('#foo');\n\n // Re-JOIN with no new messages: marker still m2 → nothing newer.\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const hasPlayback = out.effects.some(\n (e) =>\n e.tag === 'Send' &&\n e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),\n );\n expect(hasPlayback).toBe(false);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');\n });\n\n it('replays exactly the K new messages on re-JOIN after K new messages', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n seedHistory(store, '#foo', 2);\n const conn = makeCapConn();\n const ctx = makeCtx(conn, new FakeClock(10_000), store);\n\n joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');\n\n // PART.\n chan.members.delete('c1');\n conn.joinedChannels.delete('#foo');\n\n // Two new messages arrive.\n store.record({\n msgid: 'm3',\n time: 3_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'bob',\n user: 'bob',\n host: 'ex.org',\n text: 'msg 3',\n });\n store.record({\n msgid: 'm4',\n time: 4_000,\n chan: '#foo',\n command: 'PRIVMSG',\n nick: 'bob',\n user: 'bob',\n host: 'ex.org',\n text: 'msg 4',\n });\n\n // Re-JOIN: replay exactly m3, m4 (the K=2 new), then advance marker to m4.\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const send = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> =>\n e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),\n );\n const msgids = send?.lines\n .filter((l) => l.text.startsWith('@time='))\n .map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);\n expect(msgids).toEqual(['m3', 'm4']);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m4');\n });\n\n it('respects chatHistoryPlaybackLimit from ServerConfig', () => {\n const chan = makeChan('#foo');\n const store = new InMemoryMessageStore();\n seedHistory(store, '#foo', 5);\n const conn = makeCapConn();\n const ctx = buildCtx({\n serverConfig: { ...serverConfig, chatHistoryPlaybackLimit: 2 },\n clock: new FakeClock(10_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n messages: store,\n connection: conn,\n });\n\n const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);\n const send = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Send' }> =>\n e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),\n );\n const msgids = send?.lines\n .filter((l) => l.text.startsWith('@time='))\n .map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);\n // Only the most recent 2 (m4, m5) and marker advances to m5.\n expect(msgids).toEqual(['m4', 'm5']);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m5');\n });\n});\n"},"tests/commands/kick.test.ts":{"tests":[{"id":"498","name":"kickReducer — success broadcasts KICK with reason to the channel and removes the target from the roster"},{"id":"499","name":"kickReducer — success broadcasts KICK without a trailing reason when none is supplied"},{"id":"500","name":"kickReducer — success treats an empty reason param the same as no reason param"},{"id":"501","name":"kickReducer — success includes the target in the KICK broadcast (no except)"},{"id":"502","name":"kickReducer — success resolves the target case-insensitively by nick"},{"id":"503","name":"kickReducer — success resolves the target using rfc1459 case-mapping ([ and { are equal)"},{"id":"504","name":"kickReducer — success updates the kicker lastSeen to ctx.clock.now()"},{"id":"505","name":"kickReducer — success returns the same state reference (mutation permitted, no copy)"},{"id":"506","name":"kickReducer — success falls back to ? as the KICK source when the kicker has no nick (defensive)"},{"id":"507","name":"kickReducer — rejections emits 461 ERR_NEEDMOREPARAMS when no channel is supplied"},{"id":"508","name":"kickReducer — rejections emits 461 ERR_NEEDMOREPARAMS when no target is supplied"},{"id":"509","name":"kickReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix"},{"id":"510","name":"kickReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma"},{"id":"511","name":"kickReducer — rejections emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"512","name":"kickReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name exceeding the length cap"},{"id":"513","name":"kickReducer — rejections emits 442 ERR_NOTONCHANNEL when the kicker is not on the channel"},{"id":"514","name":"kickReducer — rejections emits 482 ERR_CHANOPRIVSNEEDED when the kicker is on the channel but not an op"},{"id":"515","name":"kickReducer — rejections emits 441 ERR_USERNOTINCHANNEL when the target is not on the channel"},{"id":"516","name":"kickReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { kickReducer } from '../../src/commands/kick';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\n/** Adds a member to the channel roster. */\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// kickReducer — success path\n// ============================================================================\n\ndescribe('kickReducer — success', () => {\n it('broadcasts KICK with reason to the channel and removes the target from the roster', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob', false);\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(\n chan,\n { command: 'KICK', params: ['#foo', 'bob', 'trolling'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com KICK #foo bob :trolling')]),\n Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),\n ]);\n expect(out.state.members.has('c2')).toBe(false);\n expect(out.state.members.has('c1')).toBe(true);\n });\n\n it('broadcasts KICK without a trailing reason when none is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice@example.com KICK #foo bob')]),\n );\n });\n\n it('treats an empty reason param the same as no reason param', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob', ''], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice@example.com KICK #foo bob')]),\n );\n });\n\n it('includes the target in the KICK broadcast (no except)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.except).toBeUndefined();\n });\n\n it('resolves the target case-insensitively by nick', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'Bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'BOB'], tags: {} }, ctx);\n\n expect(out.state.members.has('c2')).toBe(false);\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),\n );\n });\n\n it('resolves the target using rfc1459 case-mapping ([ and { are equal)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'foo[bar]');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'FOO{BAR}'], tags: {} }, ctx);\n\n expect(out.state.members.has('c2')).toBe(false);\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),\n );\n });\n\n it('updates the kicker lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const clock = new FakeClock(7_500);\n const ctx = makeCtx(conn, clock);\n\n kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(7_500);\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n });\n\n it('falls back to ? as the KICK source when the kicker has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', '?', true);\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':? KICK #foo bob')]),\n );\n });\n});\n\n// ============================================================================\n// kickReducer — rejections\n// ============================================================================\n\ndescribe('kickReducer — rejections', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice KICK :Not enough parameters')]),\n ]);\n expect(out.state.members.has('c2')).toBe(true);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when no target is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice KICK :Not enough parameters')]),\n ]);\n expect(out.state.members.has('c2')).toBe(true);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {\n const chan = makeChan('foo');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['foo', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {\n const chan = makeChan('#foo');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo,bar', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name exceeding the length cap', () => {\n const longName = `#${'a'.repeat(50)}`;\n const chan = makeChan(longName);\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: [longName, 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com 403 alice ${longName} :No such channel`)]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when the kicker is not on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n expect(out.state.members.has('c2')).toBe(true);\n });\n\n it('emits 482 ERR_CHANOPRIVSNEEDED when the kicker is on the channel but not an op', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', false);\n addMember(chan, 'c2', 'bob', false);\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n expect(out.state.members.has('c2')).toBe(true);\n });\n\n it('emits 441 ERR_USERNOTINCHANNEL when the target is not on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'ghost'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 441 alice ghost #foo :They are not on that channel')]),\n ]);\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', '?', true);\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = kickReducer(chan, { command: 'KICK', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * KICK :Not enough parameters')]),\n ]);\n });\n});\n"},"tests/commands/kill.test.ts":{"tests":[{"id":"517","name":"killReducer — oper happy path emits a Disconnect effect for the target with a kill-formatted reason"},{"id":"518","name":"killReducer — oper happy path uses the oper display nick in the reason, not the target nick"},{"id":"519","name":"killReducer — oper happy path formats multi-word comments verbatim inside the inner parens"},{"id":"520","name":"killReducer — oper happy path updates the oper lastSeen to ctx.clock.now()"},{"id":"521","name":"killReducer — oper happy path falls back to \"*\" for the oper nick in the reason when the oper has none"},{"id":"522","name":"killReducer — self-kill (RFC-permitted) allows an oper to KILL themselves, disconnecting their own connection"},{"id":"523","name":"killReducer — non-oper (481 ERR_NOPRIVILEGES) emits 481 when the sender is not an oper"},{"id":"524","name":"killReducer — non-oper (481 ERR_NOPRIVILEGES) does not disconnect the target when the sender is not an oper"},{"id":"525","name":"killReducer — non-oper (481 ERR_NOPRIVILEGES) gates before param validation: a non-oper with missing params still gets 481"},{"id":"526","name":"killReducer — non-oper (481 ERR_NOPRIVILEGES) uses \"*\" as the nick in 481 when the connection has no nick"},{"id":"527","name":"killReducer — missing parameters (461 ERR_NEEDMOREPARAMS) emits 461 when no parameters are supplied"},{"id":"528","name":"killReducer — missing parameters (461 ERR_NEEDMOREPARAMS) emits 461 when only the nick is supplied (no comment)"},{"id":"529","name":"killReducer — missing parameters (461 ERR_NEEDMOREPARAMS) uses \"*\" as the nick in 461 when the connection has no nick"},{"id":"530","name":"killReducer — unknown nick (401 ERR_NOSUCHNICK) emits 401 when the target resolves to undefined"},{"id":"531","name":"killReducer — unknown nick (401 ERR_NOSUCHNICK) does not emit a Disconnect when the target is unknown"}],"source":"import { describe, expect, it } from 'vitest';\nimport { killReducer } from '../../src/commands/kill';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeOper(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n s.userModes.oper = true;\n return s;\n}\n\nfunction makeTarget(id = 'c2', nick = 'bob'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Bob';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst kill = (nick?: string, comment?: string) =>\n ({\n command: 'KILL',\n params: nick === undefined ? [] : comment === undefined ? [nick] : [nick, comment],\n tags: {},\n }) as const;\n\ndescribe('killReducer — oper happy path', () => {\n it('emits a Disconnect effect for the target with a kill-formatted reason', () => {\n const oper = makeOper();\n const target = makeTarget();\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'flooding'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.disconnect('c2', 'Killed (alice (flooding))'),\n ]);\n });\n\n it('uses the oper display nick in the reason, not the target nick', () => {\n const oper = makeOper('c1', 'theOper');\n const target = makeTarget('c2', 'bob');\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'bye'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([Effect.disconnect('c2', 'Killed (theOper (bye))')]);\n });\n\n it('formats multi-word comments verbatim inside the inner parens', () => {\n const oper = makeOper();\n const target = makeTarget();\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'stop flooding the channel'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.disconnect('c2', 'Killed (alice (stop flooding the channel))'),\n ]);\n });\n\n it('updates the oper lastSeen to ctx.clock.now()', () => {\n const oper = makeOper();\n expect(oper.lastSeen).toBe(0);\n const ctx = makeCtx(oper);\n\n killReducer(makeTarget(), kill('bob', 'flooding'), ctx);\n\n expect(oper.lastSeen).toBe(5_000);\n });\n\n it('falls back to \"*\" for the oper nick in the reason when the oper has none', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.userModes.oper = true;\n const target = makeTarget();\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'x'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([Effect.disconnect('c2', 'Killed (* (x))')]);\n });\n});\n\ndescribe('killReducer — self-kill (RFC-permitted)', () => {\n it('allows an oper to KILL themselves, disconnecting their own connection', () => {\n const oper = makeOper('c1', 'alice');\n const ctx = makeCtx(oper);\n\n const out = killReducer(oper, kill('alice', 'self-terminate'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.disconnect('c1', 'Killed (alice (self-terminate))'),\n ]);\n });\n});\n\ndescribe('killReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {\n it('emits 481 when the sender is not an oper', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const target = makeTarget();\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'flooding'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('does not disconnect the target when the sender is not an oper', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const target = makeTarget();\n const ctx = makeCtx(oper);\n\n const out = killReducer(target, kill('bob', 'flooding'), ctx);\n\n expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);\n });\n\n it('gates before param validation: a non-oper with missing params still gets 481', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('uses \"*\" as the nick in 481 when the connection has no nick', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill('bob', 'x'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 * :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n});\n\ndescribe('killReducer — missing parameters (461 ERR_NEEDMOREPARAMS)', () => {\n it('emits 461 when no parameters are supplied', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),\n ]);\n });\n\n it('emits 461 when only the nick is supplied (no comment)', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = killReducer(makeTarget(), kill('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),\n ]);\n });\n\n it('uses \"*\" as the nick in 461 when the connection has no nick', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.userModes.oper = true;\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * KILL :Not enough parameters')]),\n ]);\n });\n});\n\ndescribe('killReducer — unknown nick (401 ERR_NOSUCHNICK)', () => {\n it('emits 401 when the target resolves to undefined', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),\n ]);\n });\n\n it('does not emit a Disconnect when the target is unknown', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);\n\n expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);\n });\n});\n"},"tests/commands/list.test.ts":{"tests":[{"id":"532","name":"listReducer — no args (enumerate visible channels) emits 321, one 322 per public channel ordered by snapshot order, then 323"},{"id":"533","name":"listReducer — no args (enumerate visible channels) includes the topic text in the 322 trailing when a topic is set"},{"id":"534","name":"listReducer — no args (enumerate visible channels) hides secret (+s) channels the requester has not joined"},{"id":"535","name":"listReducer — no args (enumerate visible channels) reveals secret (+s) channels the requester HAS joined"},{"id":"536","name":"listReducer — no args (enumerate visible channels) emits 321 then 323 with no 322s when there are no visible channels"},{"id":"537","name":"listReducer — explicit channel list emits a 322 only for the requested channels"},{"id":"538","name":"listReducer — explicit channel list handles a single-channel argument"},{"id":"539","name":"listReducer — explicit channel list silently skips requested channels that do not exist"},{"id":"540","name":"listReducer — explicit channel list silently skips requested channels with invalid names"},{"id":"541","name":"listReducer — explicit channel list silently skips requested channels whose names exceed the configured length cap"},{"id":"542","name":"listReducer — explicit channel list still filters secret channels when explicitly named by a non-member"},{"id":"543","name":"listReducer — explicit channel list performs case-insensitive channel-name matching"},{"id":"544","name":"listReducer — explicit channel list preserves the original channel name in the 322 line (preserved-case)"},{"id":"545","name":"listReducer — result cap caps the number of 322 entries at serverConfig.maxListEntries (no args)"},{"id":"546","name":"listReducer — result cap caps the number of 322 entries when the explicit list exceeds the cap"},{"id":"547","name":"listReducer — bookkeeping updates the connection lastSeen to ctx.clock.now()"},{"id":"548","name":"listReducer — bookkeeping returns the same state reference (read-only reducer)"},{"id":"549","name":"listReducer — bookkeeping uses * in numeric replies when the connection has no nick (defensive)"},{"id":"550","name":"listReducer — bookkeeping treats an empty trailing param as no args"},{"id":"551","name":"listReducer — bookkeeping treats a whitespace-only trailing param as no args"}],"source":"import { describe, expect, it } from 'vitest';\nimport { listReducer } from '../../src/commands/list';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport {\n type ChanSnapshot,\n type ChannelState,\n createChannel,\n toSnapshot,\n} from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n conn: ConnectionState,\n overrides: Partial<"+"ServerConfig> = {},\n clock = new FakeClock(1_000),\n): Ctx {\n return buildCtx({\n serverConfig: { ...serverConfig, ...overrides },\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nfunction snapshot(chan: ChannelState): ChanSnapshot {\n return toSnapshot(chan);\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// listReducer — LIST with no args\n// ============================================================================\n\ndescribe('listReducer — no args (enumerate visible channels)', () => {\n it('emits 321, one 322 per public channel ordered by snapshot order, then 323', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const bar = makeChan('#bar');\n addMember(bar, 'c3', 'carol');\n const channels = [snapshot(foo), snapshot(bar)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),\n Effect.send('c1', [\n L(':irc.example.com 322 alice #foo 1 :'),\n L(':irc.example.com 322 alice #bar 1 :'),\n ]),\n Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),\n ]);\n });\n\n it('includes the topic text in the 322 trailing when a topic is set', () => {\n const foo = makeChan('#foo');\n foo.topic = { text: 'Welcome to foo', setter: 'bob', setAt: 0 };\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :Welcome to foo');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('hides secret (+s) channels the requester has not joined', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const secret = makeChan('#secret');\n secret.modes.secret = true;\n addMember(secret, 'c3', 'carol');\n const channels = [snapshot(foo), snapshot(secret)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toHaveLength(1);\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #foo 1 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('reveals secret (+s) channels the requester HAS joined', () => {\n const secret = makeChan('#secret');\n secret.modes.secret = true;\n addMember(secret, 'c1', 'alice');\n addMember(secret, 'c2', 'bob');\n const channels = [snapshot(secret)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #secret 2 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('emits 321 then 323 with no 322s when there are no visible channels', () => {\n const channels: ChanSnapshot[] = [];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),\n Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),\n ]);\n });\n});\n\n// ============================================================================\n// listReducer — LIST with explicit channel list\n// ============================================================================\n\ndescribe('listReducer — explicit channel list', () => {\n it('emits a 322 only for the requested channels', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const bar = makeChan('#bar');\n addMember(bar, 'c3', 'carol');\n const baz = makeChan('#baz');\n addMember(baz, 'c4', 'dave');\n const channels = [snapshot(foo), snapshot(bar), snapshot(baz)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#foo,#baz'], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([\n L(':irc.example.com 322 alice #foo 1 :'),\n L(':irc.example.com 322 alice #baz 1 :'),\n ]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('handles a single-channel argument', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#foo'], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('silently skips requested channels that do not exist', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#foo,#nope'], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('silently skips requested channels with invalid names', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(\n channels,\n { command: 'LIST', params: ['#foo,bogus,#foo,bar'], tags: {} },\n ctx,\n );\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('silently skips requested channels whose names exceed the configured length cap', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n // channelLen is 50 by default; supply an over-long name alongside a valid one.\n const longName = `#${'x'.repeat(60)}`;\n const ctx = makeCtx(conn);\n\n const out = listReducer(\n channels,\n { command: 'LIST', params: [`#foo,${longName}`], tags: {} },\n ctx,\n );\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('still filters secret channels when explicitly named by a non-member', () => {\n const secret = makeChan('#secret');\n secret.modes.secret = true;\n addMember(secret, 'c2', 'bob');\n const channels = [snapshot(secret)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#secret'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 321 alice Channel :Users Name')]),\n Effect.send('c1', [L(':irc.example.com 323 alice :End of /LIST')]),\n ]);\n });\n\n it('performs case-insensitive channel-name matching', () => {\n const foo = makeChan('#Foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#foo'], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #Foo 1 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('preserves the original channel name in the 322 line (preserved-case)', () => {\n const foo = makeChan('#Foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: ['#FOO'], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #Foo 1 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n});\n\n// ============================================================================\n// listReducer — result cap (thundering-herd mitigation)\n// ============================================================================\n\ndescribe('listReducer — result cap', () => {\n it('caps the number of 322 entries at serverConfig.maxListEntries (no args)', () => {\n const chans: ChanSnapshot[] = [];\n for (let i = 0; i <"+" 10; i++) {\n const c = makeChan(`#c${i}`);\n addMember(c, `cx${i}`, `user${i}`);\n chans.push(snapshot(c));\n }\n const conn = makeConn();\n const ctx = makeCtx(conn, { maxListEntries: 3 });\n\n const out = listReducer(chans, { command: 'LIST', params: [], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toHaveLength(3);\n // Iteration order preserved: first three channels.\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #c0 1 :');\n expect(list.lines[2]?.text).toBe(':irc.example.com 322 alice #c2 1 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('caps the number of 322 entries when the explicit list exceeds the cap', () => {\n const chans: ChanSnapshot[] = [];\n for (let i = 0; i <"+" 5; i++) {\n const c = makeChan(`#c${i}`);\n addMember(c, `cx${i}`, `user${i}`);\n chans.push(snapshot(c));\n }\n const conn = makeConn();\n const ctx = makeCtx(conn, { maxListEntries: 2 });\n\n const out = listReducer(\n chans,\n { command: 'LIST', params: ['#c0,#c1,#c2,#c3,#c4'], tags: {} },\n ctx,\n );\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toHaveLength(2);\n expect(list.lines[0]?.text).toBe(':irc.example.com 322 alice #c0 1 :');\n expect(list.lines[1]?.text).toBe(':irc.example.com 322 alice #c1 1 :');\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n});\n\n// ============================================================================\n// listReducer — bookkeeping\n// ============================================================================\n\ndescribe('listReducer — bookkeeping', () => {\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(conn, {}, clock);\n\n listReducer([], { command: 'LIST', params: [], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(7_700);\n });\n\n it('returns the same state reference (read-only reducer)', () => {\n const channels: ChanSnapshot[] = [];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [], tags: {} }, ctx);\n\n expect(out.state).toBe(channels);\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = listReducer([], { command: 'LIST', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 321 * Channel :Users Name')]),\n Effect.send('c1', [L(':irc.example.com 323 * :End of /LIST')]),\n ]);\n });\n\n it('treats an empty trailing param as no args', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [''], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n\n it('treats a whitespace-only trailing param as no args', () => {\n const foo = makeChan('#foo');\n addMember(foo, 'c2', 'bob');\n const channels = [snapshot(foo)];\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = listReducer(channels, { command: 'LIST', params: [' '], tags: {} }, ctx);\n\n const list = out.effects[1];\n if (list?.tag === 'Send') {\n expect(list.lines).toEqual<"+"RawLine[]>([L(':irc.example.com 322 alice #foo 1 :')]);\n } else {\n throw new Error('expected Send effect for RPL_LIST');\n }\n });\n});\n"},"tests/commands/lusers.test.ts":{"tests":[{"id":"552","name":"lusersReducer — 251–255 baseline emits 251 RPL_LUSERCLIENT with the visible/invisible/server counts"},{"id":"553","name":"lusersReducer — 251–255 baseline emits 255 RPL_LUSERME with the local client/server counts"},{"id":"554","name":"lusersReducer — 251–255 baseline updates the requester lastSeen to ctx.clock.now()"},{"id":"555","name":"lusersReducer — 251–255 baseline uses \"*\" as the nick when the requester has none"},{"id":"556","name":"lusersReducer — non-zero omission emits 252 RPL_LUSEROP when opers is non-zero"},{"id":"557","name":"lusersReducer — non-zero omission omits 252 when opers is zero"},{"id":"558","name":"lusersReducer — non-zero omission emits 253 RPL_LUSERUNKNOWN when unknownConnections is non-zero"},{"id":"559","name":"lusersReducer — non-zero omission omits 253 when unknownConnections is zero"},{"id":"560","name":"lusersReducer — non-zero omission emits 254 RPL_LUSERCHANNELS when channels is non-zero"},{"id":"561","name":"lusersReducer — non-zero omission omits 254 when channels is zero"},{"id":"562","name":"lusersReducer — 265/266 local/global emits 265 RPL_LOCALUSERS with the current and max local counts"},{"id":"563","name":"lusersReducer — 265/266 local/global emits 266 RPL_GLOBALUSERS with the current and max global counts"},{"id":"564","name":"lusersReducer — 265/266 local/global always emits 265 and 266 (modern clients rely on them for the user list count)"},{"id":"565","name":"lusersReducer — empty deployment still emits 251 and 255 reporting zero counts"},{"id":"566","name":"lusersReducer — remote server target emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target"},{"id":"567","name":"lusersReducer — remote server target uses \"*\" as the nick in 402 when the requester has none"},{"id":"568","name":"lusersReducer — remote server target does not emit 402 when no serverTarget is flagged"},{"id":"569","name":"lusersReducer — ordering emits numerics in ascending code order (251, 252, 253, 254, 255, 265, 266)"},{"id":"570","name":"lusersReducer — \"*\" nick fallback for count/usage lines uses \"*\" in 252/253/254/265/266 when the requester has no nick"}],"source":"import { describe, expect, it } from 'vitest';\nimport { lusersReducer } from '../../src/commands/lusers';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n SequentialIdFactory,\n type ServerStatsSnapshot,\n} from '../../src/ports';\nimport { createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(connId = 'c1', nick = 'alice', clock = new FakeClock(5_000)): Ctx {\n const conn = createConnection({ id: connId, connectedSince: 0 });\n conn.nick = nick;\n conn.user = nick;\n conn.host = 'example.com';\n conn.realname = 'Alice';\n conn.registration = 'registered';\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst lusers = (...params: string[]) =>\n ({\n command: 'LUSERS',\n params,\n tags: {},\n }) as const;\n\nconst snap = (overrides: Partial<"+"ServerStatsSnapshot> = {}): ServerStatsSnapshot => ({\n users: 2,\n invisible: 1,\n opers: 0,\n unknownConnections: 0,\n channels: 0,\n servers: 1,\n localConns: 3,\n globalConns: 3,\n maxLocalConns: 5,\n maxGlobalConns: 5,\n uptimeStartedAt: 1_000,\n ...overrides,\n});\n\n// ============================================================================\n// lusersReducer — 251–255 baseline\n// ============================================================================\n\ndescribe('lusersReducer — 251–255 baseline', () => {\n it('emits 251 RPL_LUSERCLIENT with the visible/invisible/server counts', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap(), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [\n L(':irc.example.com 251 alice :There are 2 users and 1 invisible on 1 servers'),\n ]),\n );\n });\n\n it('emits 255 RPL_LUSERME with the local client/server counts', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap(), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 255 alice :I have 3 clients and 1 servers')]),\n );\n });\n\n it('updates the requester lastSeen to ctx.clock.now()', () => {\n const ctx = makeCtx();\n expect(ctx.connection.lastSeen).toBe(0);\n\n lusersReducer(snap(), lusers(), ctx);\n\n expect(ctx.connection.lastSeen).toBe(5_000);\n });\n\n it('uses \"*\" as the nick when the requester has none', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = lusersReducer(snap(), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [\n L(':irc.example.com 251 * :There are 2 users and 1 invisible on 1 servers'),\n ]),\n );\n });\n});\n\n// ============================================================================\n// lusersReducer — non-zero omission (RFC: only emit 252/253/254 when non-zero)\n// ============================================================================\n\ndescribe('lusersReducer — non-zero omission', () => {\n it('emits 252 RPL_LUSEROP when opers is non-zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ opers: 2 }), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 252 alice 2 :operator(s) online')]),\n );\n });\n\n it('omits 252 when opers is zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ opers: 0 }), lusers(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 252 ');\n });\n\n it('emits 253 RPL_LUSERUNKNOWN when unknownConnections is non-zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ unknownConnections: 4 }), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 253 alice 4 :unknown connection(s)')]),\n );\n });\n\n it('omits 253 when unknownConnections is zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ unknownConnections: 0 }), lusers(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 253 ');\n });\n\n it('emits 254 RPL_LUSERCHANNELS when channels is non-zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ channels: 7 }), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 254 alice 7 :channel(s) formed')]),\n );\n });\n\n it('omits 254 when channels is zero', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ channels: 0 }), lusers(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 254 ');\n });\n});\n\n// ============================================================================\n// lusersReducer — 265/266 local/global context\n// ============================================================================\n\ndescribe('lusersReducer — 265/266 local/global', () => {\n it('emits 265 RPL_LOCALUSERS with the current and max local counts', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ localConns: 3, maxLocalConns: 5 }), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 265 alice 3 5 :Current local users 3, max 5')]),\n );\n });\n\n it('emits 266 RPL_GLOBALUSERS with the current and max global counts', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap({ globalConns: 3, maxGlobalConns: 5 }), lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 266 alice 3 5 :Current global users 3, max 5')]),\n );\n });\n\n it('always emits 265 and 266 (modern clients rely on them for the user list count)', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap(), lusers(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).toContain(' 265 ');\n expect(text).toContain(' 266 ');\n });\n});\n\n// ============================================================================\n// lusersReducer — empty deployment\n// ============================================================================\n\ndescribe('lusersReducer — empty deployment', () => {\n it('still emits 251 and 255 reporting zero counts', () => {\n const ctx = makeCtx();\n const empty: ServerStatsSnapshot = {\n users: 0,\n invisible: 0,\n opers: 0,\n unknownConnections: 0,\n channels: 0,\n servers: 1,\n localConns: 0,\n globalConns: 0,\n maxLocalConns: 0,\n maxGlobalConns: 0,\n uptimeStartedAt: 1_000,\n };\n\n const out = lusersReducer(empty, lusers(), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [\n L(':irc.example.com 251 alice :There are 0 users and 0 invisible on 1 servers'),\n ]),\n );\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 255 alice :I have 0 clients and 1 servers')]),\n );\n });\n});\n\n// ============================================================================\n// lusersReducer — remote server target\n// ============================================================================\n\ndescribe('lusersReducer — remote server target', () => {\n it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),\n ]);\n });\n\n it('uses \"*\" as the nick in 402 when the requester has none', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),\n ]);\n });\n\n it('does not emit 402 when no serverTarget is flagged', () => {\n const ctx = makeCtx();\n\n const out = lusersReducer(snap(), lusers(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 402 ');\n });\n});\n\n// ============================================================================\n// lusersReducer — ordering\n// ============================================================================\n\ndescribe('lusersReducer — ordering', () => {\n it('emits numerics in ascending code order (251, 252, 253, 254, 255, 265, 266)', () => {\n const ctx = makeCtx();\n const populated = snap({\n opers: 1,\n unknownConnections: 1,\n channels: 1,\n });\n\n const out = lusersReducer(populated, lusers(), ctx);\n\n const codes: number[] = [];\n for (const e of out.effects) {\n if (e.tag !== 'Send') continue;\n for (const line of e.lines) {\n const m = line.text.match(/\\s(\\d{3})\\s/);\n if (m) codes.push(Number(m[1]));\n }\n }\n expect(codes).toEqual([251, 252, 253, 254, 255, 265, 266]);\n });\n});\n\n// ============================================================================\n// lusersReducer — \"*\" nick fallback for count/usage lines\n// ============================================================================\n\ndescribe('lusersReducer — \"*\" nick fallback for count/usage lines', () => {\n it('uses \"*\" in 252/253/254/265/266 when the requester has no nick', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = lusersReducer(\n snap({ opers: 1, unknownConnections: 1, channels: 1 }),\n lusers(),\n ctx,\n );\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n // Every emitted numeric uses \"*\" as the requester nick.\n expect(text).toContain(' 252 * 1 ');\n expect(text).toContain(' 253 * 1 ');\n expect(text).toContain(' 254 * 1 ');\n expect(text).toContain(' 265 * 3 5 ');\n expect(text).toContain(' 266 * 3 5 ');\n });\n});\n"},"tests/commands/mode.test.ts":{"tests":[{"id":"571","name":"channelModeReducer — boolean modes sets +t when caller is op and broadcasts MODE +t to the channel"},{"id":"572","name":"channelModeReducer — boolean modes clears -t when caller is op"},{"id":"573","name":"channelModeReducer — boolean modes sets +i (inviteOnly) and clears -i"},{"id":"574","name":"channelModeReducer — boolean modes sets +t (topicLock) and clears -t"},{"id":"575","name":"channelModeReducer — boolean modes sets +n (noExternal) and clears -n"},{"id":"576","name":"channelModeReducer — boolean modes sets +m (moderated) and clears -m"},{"id":"577","name":"channelModeReducer — boolean modes sets +s (secret) and clears -s"},{"id":"578","name":"channelModeReducer — boolean modes sets +p (private) and clears -p"},{"id":"579","name":"channelModeReducer — boolean modes combines multiple boolean mode changes into one broadcast"},{"id":"580","name":"channelModeReducer — boolean modes toggles mixed sign changes preserving sign boundaries"},{"id":"581","name":"channelModeReducer — prefix modes +o <"+"nick> grants op to the named member and broadcasts MODE +o nick"},{"id":"582","name":"channelModeReducer — prefix modes +oo <"+"nick1> <"+"nick2> grants op to both members in one combined delta"},{"id":"583","name":"channelModeReducer — prefix modes -o <"+"nick> removes op from the named member"},{"id":"584","name":"channelModeReducer — prefix modes +v <"+"nick> grants voice"},{"id":"585","name":"channelModeReducer — prefix modes -v <"+"nick> removes voice"},{"id":"586","name":"channelModeReducer — prefix modes matches nicks case-insensitively for +o"},{"id":"587","name":"channelModeReducer — prefix modes emits 441 ERR_USERNOTINCHANNEL when +o target is not on the channel"},{"id":"588","name":"channelModeReducer — prefix modes emits 461 ERR_NEEDMOREPARAMS when +o has no nick argument"},{"id":"589","name":"channelModeReducer — key (k) +k <"+"key> sets the channel key and broadcasts MODE +k key"},{"id":"590","name":"channelModeReducer — key (k) -k clears the channel key without requiring an argument"},{"id":"591","name":"channelModeReducer — key (k) emits 461 ERR_NEEDMOREPARAMS when +k has no argument"},{"id":"592","name":"channelModeReducer — limit (l) +l <"+"N> sets the channel limit and broadcasts MODE +l N"},{"id":"593","name":"channelModeReducer — limit (l) -l clears the channel limit without requiring an argument"},{"id":"594","name":"channelModeReducer — limit (l) emits 461 ERR_NEEDMOREPARAMS when +l has no argument"},{"id":"595","name":"channelModeReducer — limit (l) emits 461 ERR_NEEDMOREPARAMS when +l argument is non-numeric"},{"id":"596","name":"channelModeReducer — ban masks +b <"+"mask> adds to the ban list and broadcasts MODE +b mask"},{"id":"597","name":"channelModeReducer — ban masks +bb <"+"mask1> <"+"mask2> adds both masks in one combined delta"},{"id":"598","name":"channelModeReducer — ban masks -b <"+"mask> removes from the ban list"},{"id":"599","name":"channelModeReducer — ban masks emits 367 RPL_BANLIST for each ban mask then 368 ENDOFBANLIST when querying with +b alone"},{"id":"600","name":"channelModeReducer — ban masks uses * in 367/368 replies when the connection has no nick (defensive)"},{"id":"601","name":"channelModeReducer — ban masks emits only 368 ENDOFBANLIST when there are no bans"},{"id":"602","name":"channelModeReducer — ban masks queries the ban list inline when +b appears in a combined modestring without an arg"},{"id":"603","name":"channelModeReducer — ban masks emits 461 ERR_NEEDMOREPARAMS when +b has no argument and the list is queried empty (still emits 368)"},{"id":"604","name":"channelModeReducer — read emits 324 RPL_CHANNELMODEIS with the current boolean modes"},{"id":"605","name":"channelModeReducer — read includes k and l parameters in 324 when set"},{"id":"606","name":"channelModeReducer — read emits 324 with bare + when no modes are set"},{"id":"607","name":"channelModeReducer — read uses * in 324 reply when the connection has no nick (defensive)"},{"id":"608","name":"channelModeReducer — read allows non-member to read modes of a public channel"},{"id":"609","name":"channelModeReducer — read emits 442 ERR_NOTONCHANNEL when a non-member queries a +s channel"},{"id":"610","name":"channelModeReducer — read emits 442 ERR_NOTONCHANNEL when a non-member attempts to change modes on a +s channel"},{"id":"611","name":"channelModeReducer — authorization & errors emits 482 ERR_CHANOPRIVSNEEDED when a non-op attempts a mode change"},{"id":"612","name":"channelModeReducer — authorization & errors emits 482 when a non-op attempts +o on someone"},{"id":"613","name":"channelModeReducer — authorization & errors emits 442 ERR_NOTONCHANNEL when a non-member attempts a mode change"},{"id":"614","name":"channelModeReducer — authorization & errors emits 472 ERR_UNKNOWNMODE for an unknown mode char and continues processing the rest"},{"id":"615","name":"channelModeReducer — authorization & errors applies valid mode chars and reports unknown ones in the same modestring"},{"id":"616","name":"channelModeReducer — authorization & errors emits 461 ERR_NEEDMOREPARAMS when no channel argument is supplied"},{"id":"617","name":"channelModeReducer — authorization & errors emits 403 ERR_NOSUCHCHANNEL for an invalid channel name"},{"id":"618","name":"channelModeReducer — authorization & errors emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"619","name":"channelModeReducer — authorization & errors uses * in error replies when the connection has no nick (defensive)"},{"id":"620","name":"channelModeReducer — authorization & errors broadcasts MODE with the bare hostmask (? fallback) when nick is undefined"},{"id":"621","name":"channelModeReducer — authorization & errors updates the connection lastSeen to ctx.clock.now()"},{"id":"622","name":"userModeReducer — read emits 221 RPL_UMODEIS with the current user modes"},{"id":"623","name":"userModeReducer — read emits 221 with bare + when no user modes are set"},{"id":"624","name":"userModeReducer — write sets +i (invisible) and broadcasts no echo (local-only state change)"},{"id":"625","name":"userModeReducer — write clears -i"},{"id":"626","name":"userModeReducer — write toggles +w / -w (wallops)"},{"id":"627","name":"userModeReducer — write toggles +s / -s (server notices)"},{"id":"628","name":"userModeReducer — write rejects +o via MODE with 481 ERR_NOPRIVILEGES (oper is granted via OPER only)"},{"id":"629","name":"userModeReducer — write allows an existing oper to drop +o via -o"},{"id":"630","name":"userModeReducer — write combines multiple user mode changes into one echo"},{"id":"631","name":"userModeReducer — write emits 501 ERR_UMODEUNKNOWNFLAG for an unknown user mode char"},{"id":"632","name":"userModeReducer — write emits 461 ERR_NEEDMOREPARAMS when no nick argument is supplied"},{"id":"633","name":"userModeReducer — write emits 502 ERR_USERSDONTMATCH when the target nick is not the connection"},{"id":"634","name":"userModeReducer — write updates lastSeen to ctx.clock.now()"},{"id":"635","name":"userModeReducer — read-only mode `S` (TLS connected) emits 221 with +S when the connection has the tls user mode set"},{"id":"636","name":"userModeReducer — read-only mode `S` (TLS connected) emits 221 with +iS when invisible and tls are both set"},{"id":"637","name":"userModeReducer — read-only mode `S` (TLS connected) rejects MODE nick +S with 501 ERR_UMODEUNKNOWNFLAG (read-only)"},{"id":"638","name":"userModeReducer — read-only mode `S` (TLS connected) rejects MODE nick -S with 501 ERR_UMODEUNKNOWNFLAG even when tls is set"},{"id":"639","name":"userModeReducer — read-only mode `S` (TLS connected) does not echo a MODE line when only +S was supplied (rejected, no-op)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { channelModeReducer, userModeReducer } from '../../src/commands/mode';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// channelModeReducer — boolean modes\n// ============================================================================\n\ndescribe('channelModeReducer — boolean modes', () => {\n it('sets +t when caller is op and broadcasts MODE +t to the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+t'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),\n ]);\n });\n\n it('clears -t when caller is op', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-t'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(false);\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -t')]),\n );\n });\n\n const booleanModes: Array<"+"[string, keyof ChannelState['modes']]> = [\n ['i', 'inviteOnly'],\n ['t', 'topicLock'],\n ['n', 'noExternal'],\n ['m', 'moderated'],\n ['s', 'secret'],\n ['p', 'private'],\n ];\n for (const [letter, field] of booleanModes) {\n it(`sets +${letter} (${field}) and clears -${letter}`, () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n channelModeReducer(chan, { command: 'MODE', params: ['#foo', `+${letter}`], tags: {} }, ctx);\n expect(chan.modes[field]).toBe(true);\n\n channelModeReducer(chan, { command: 'MODE', params: ['#foo', `-${letter}`], tags: {} }, ctx);\n expect(chan.modes[field]).toBe(false);\n });\n }\n\n it('combines multiple boolean mode changes into one broadcast', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+tn'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(true);\n expect(chan.modes.noExternal).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n modeChanges: [\n { mode: 'topicLock', set: true },\n { mode: 'noExternal', set: true },\n ],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +tn')]),\n ]);\n });\n\n it('toggles mixed sign changes preserving sign boundaries', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-t+n'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(false);\n expect(chan.modes.noExternal).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n modeChanges: [\n { mode: 'topicLock', set: false },\n { mode: 'noExternal', set: true },\n ],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -t+n')]),\n ]);\n });\n});\n\n// ============================================================================\n// channelModeReducer — prefix modes (o, v)\n// ============================================================================\n\ndescribe('channelModeReducer — prefix modes', () => {\n it('+o <"+"nick> grants op to the named member and broadcasts MODE +o nick', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+o', 'bob'], tags: {} },\n ctx,\n );\n\n expect(chan.members.get('c2')?.op).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: true, voice: false }],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +o bob')]),\n ]);\n });\n\n it('+oo <"+"nick1> <"+"nick2> grants op to both members in one combined delta', () => {\n // Exercises the already-initialized branch of `pushMembershipChange`:\n // the second membership push reuses the `delta.memberships` array the\n // first one created.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n addMember(chan, 'c3', 'carol');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+oo', 'bob', 'carol'], tags: {} },\n ctx,\n );\n\n expect(chan.members.get('c2')?.op).toBe(true);\n expect(chan.members.get('c3')?.op).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [\n { type: 'add', conn: 'c2', nick: 'bob', op: true, voice: false },\n { type: 'add', conn: 'c3', nick: 'carol', op: true, voice: false },\n ],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +oo bob carol')]),\n ]);\n });\n\n it('-o <"+"nick> removes op from the named member', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-o', 'bob'], tags: {} },\n ctx,\n );\n\n expect(chan.members.get('c2')?.op).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: false }],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -o bob')]),\n ]);\n });\n\n it('+v <"+"nick> grants voice', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+v', 'bob'], tags: {} },\n ctx,\n );\n\n expect(chan.members.get('c2')?.voice).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: true }],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +v bob')]),\n ]);\n });\n\n it('-v <"+"nick> removes voice', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob', false, true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-v', 'bob'], tags: {} },\n ctx,\n );\n\n expect(chan.members.get('c2')?.voice).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n memberships: [{ type: 'add', conn: 'c2', nick: 'bob', op: false, voice: false }],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -v bob')]),\n ]);\n });\n\n it('matches nicks case-insensitively for +o', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'Bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n channelModeReducer(chan, { command: 'MODE', params: ['#foo', '+o', 'BOB'], tags: {} }, ctx);\n\n expect(chan.members.get('c2')?.op).toBe(true);\n });\n\n it('emits 441 ERR_USERNOTINCHANNEL when +o target is not on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+o', 'carol'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 441 alice carol #foo :They are not on that channel')]),\n ]);\n expect(chan.members.size).toBe(1);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when +o has no nick argument', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+o'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice o :Not enough parameters')]),\n ]);\n });\n});\n\n// ============================================================================\n// channelModeReducer — key (k)\n// ============================================================================\n\ndescribe('channelModeReducer — key (k)', () => {\n it('+k <"+"key> sets the channel key and broadcasts MODE +k key', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+k', 'secret'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.key).toBe('secret');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n modeChanges: [{ mode: 'key', set: true, arg: 'secret' }],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +k secret')]),\n ]);\n });\n\n it('-k clears the channel key without requiring an argument', () => {\n const chan = makeChan('#foo');\n chan.modes.key = 'secret';\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-k'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.key).toBeUndefined();\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'key', set: false }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -k')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when +k has no argument', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+k'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice k :Not enough parameters')]),\n ]);\n expect(chan.modes.key).toBeUndefined();\n });\n});\n\n// ============================================================================\n// channelModeReducer — limit (l)\n// ============================================================================\n\ndescribe('channelModeReducer — limit (l)', () => {\n it('+l <"+"N> sets the channel limit and broadcasts MODE +l N', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+l', '30'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.limit).toBe(30);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'limit', set: true, arg: 30 }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +l 30')]),\n ]);\n });\n\n it('-l clears the channel limit without requiring an argument', () => {\n const chan = makeChan('#foo');\n chan.modes.limit = 30;\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-l'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.limit).toBeUndefined();\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'limit', set: false }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -l')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when +l has no argument', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+l'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice l :Not enough parameters')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when +l argument is non-numeric', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+l', 'big'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice l :Not enough parameters')]),\n ]);\n expect(chan.modes.limit).toBeUndefined();\n });\n});\n\n// ============================================================================\n// channelModeReducer — ban masks (b)\n// ============================================================================\n\ndescribe('channelModeReducer — ban masks', () => {\n it('+b <"+"mask> adds to the ban list and broadcasts MODE +b mask', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+b', 'bob!*@*'], tags: {} },\n ctx,\n );\n\n expect([...chan.banMasks]).toEqual(['bob!*@*']);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { banMaskChanges: [{ type: 'add', mask: 'bob!*@*' }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +b bob!*@*')]),\n ]);\n });\n\n it('+bb <"+"mask1> <"+"mask2> adds both masks in one combined delta', () => {\n // Exercises the already-initialized branch of `pushBanMaskChange`: the\n // second ban push reuses the `delta.banMaskChanges` array the first one\n // created.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+bb', 'bob!*@*', 'carol!*@*'], tags: {} },\n ctx,\n );\n\n expect([...chan.banMasks]).toEqual(['bob!*@*', 'carol!*@*']);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n banMaskChanges: [\n { type: 'add', mask: 'bob!*@*' },\n { type: 'add', mask: 'carol!*@*' },\n ],\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +bb bob!*@* carol!*@*')]),\n ]);\n });\n\n it('-b <"+"mask> removes from the ban list', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('bob!*@*');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '-b', 'bob!*@*'], tags: {} },\n ctx,\n );\n\n expect([...chan.banMasks]).toEqual([]);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { banMaskChanges: [{ type: 'remove', mask: 'bob!*@*' }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo -b bob!*@*')]),\n ]);\n });\n\n it('emits 367 RPL_BANLIST for each ban mask then 368 ENDOFBANLIST when querying with +b alone', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('bob!*@*');\n chan.banMasks.add('carol!*@*');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+b'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 367 alice #foo bob!*@*')]),\n Effect.send('c1', [L(':irc.example.com 367 alice #foo carol!*@*')]),\n Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),\n ]);\n });\n\n it('uses * in 367/368 replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('bob!*@*');\n addMember(chan, 'c1', 'alice', true);\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo', 'b'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 367 * #foo bob!*@*')]),\n Effect.send('c1', [L(':irc.example.com 368 * #foo :End of Channel Ban List')]),\n ]);\n });\n\n it('emits only 368 ENDOFBANLIST when there are no bans', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo', 'b'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),\n ]);\n });\n\n it('queries the ban list inline when +b appears in a combined modestring without an arg', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('bob!*@*');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+tb'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 367 alice #foo bob!*@*')]),\n Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when +b has no argument and the list is queried empty (still emits 368)', () => {\n // Per modern ircd behavior, +b alone queries the list. So no 461 here.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+b'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 368 alice #foo :End of Channel Ban List')]),\n ]);\n });\n});\n\n// ============================================================================\n// channelModeReducer — read form\n// ============================================================================\n\ndescribe('channelModeReducer — read', () => {\n it('emits 324 RPL_CHANNELMODEIS with the current boolean modes', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n chan.modes.noExternal = true;\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 324 alice #foo +tn')]),\n ]);\n });\n\n it('includes k and l parameters in 324 when set', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n chan.modes.key = 'secret';\n chan.modes.limit = 50;\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 324 alice #foo +tkl secret 50')]),\n ]);\n });\n\n it('emits 324 with bare + when no modes are set', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 324 alice #foo +')]),\n ]);\n });\n\n it('uses * in 324 reply when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c1', 'alice');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 324 * #foo +t')]),\n ]);\n });\n\n it('allows non-member to read modes of a public channel', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 324 alice #foo +t')]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when a non-member queries a +s channel', () => {\n const chan = makeChan('#foo');\n chan.modes.secret = true;\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when a non-member attempts to change modes on a +s channel', () => {\n const chan = makeChan('#foo');\n chan.modes.secret = true;\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+t'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n expect(chan.modes.topicLock).toBe(false);\n });\n});\n\n// ============================================================================\n// channelModeReducer — authorization & errors\n// ============================================================================\n\ndescribe('channelModeReducer — authorization & errors', () => {\n it('emits 482 ERR_CHANOPRIVSNEEDED when a non-op attempts a mode change', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+t'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n expect(chan.modes.topicLock).toBe(false);\n });\n\n it('emits 482 when a non-op attempts +o on someone', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', false);\n addMember(chan, 'c2', 'bob', false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+o', 'bob'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n expect(chan.members.get('c2')?.op).toBe(false);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when a non-member attempts a mode change', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+t'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n });\n\n it('emits 472 ERR_UNKNOWNMODE for an unknown mode char and continues processing the rest', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+xz'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 472 alice x :is unknown mode char to me')]),\n Effect.send('c1', [L(':irc.example.com 472 alice z :is unknown mode char to me')]),\n ]);\n expect(chan.modes.topicLock).toBe(false);\n });\n\n it('applies valid mode chars and reports unknown ones in the same modestring', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+tz'], tags: {} },\n ctx,\n );\n\n expect(chan.modes.topicLock).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 472 alice z :is unknown mode char to me')]),\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +t')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when no channel argument is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MODE :Not enough parameters')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {\n const chan = makeChan('foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['foo', '+t'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: ['', '+t'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('uses * in error replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(chan, { command: 'MODE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * MODE :Not enough parameters')]),\n ]);\n });\n\n it('broadcasts MODE with the bare hostmask (? fallback) when nick is undefined', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = channelModeReducer(\n chan,\n { command: 'MODE', params: ['#foo', '+t'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { modeChanges: [{ mode: 'topicLock', set: true }] }),\n Effect.broadcast('#foo', [L(':? MODE #foo +t')]),\n ]);\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const clock = new FakeClock(9_000);\n const ctx = makeCtx(conn, clock);\n\n channelModeReducer(chan, { command: 'MODE', params: ['#foo', '+t'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(9_000);\n });\n});\n\n// ============================================================================\n// userModeReducer\n// ============================================================================\n\ndescribe('userModeReducer — read', () => {\n it('emits 221 RPL_UMODEIS with the current user modes', () => {\n const conn = makeConn();\n conn.userModes.invisible = true;\n conn.userModes.wallops = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 221 alice +iw')]),\n ]);\n });\n\n it('emits 221 with bare + when no user modes are set', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 221 alice +')]),\n ]);\n });\n});\n\ndescribe('userModeReducer — write', () => {\n it('sets +i (invisible) and broadcasts no echo (local-only state change)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+i'], tags: {} }, ctx);\n\n expect(conn.userModes.invisible).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':alice!alice@example.com MODE alice +i')]),\n ]);\n });\n\n it('clears -i', () => {\n const conn = makeConn();\n conn.userModes.invisible = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-i'], tags: {} }, ctx);\n\n expect(conn.userModes.invisible).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':alice!alice@example.com MODE alice -i')]),\n ]);\n });\n\n it('toggles +w / -w (wallops)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n userModeReducer(conn, { command: 'MODE', params: ['alice', '+w'], tags: {} }, ctx);\n expect(conn.userModes.wallops).toBe(true);\n\n userModeReducer(conn, { command: 'MODE', params: ['alice', '-w'], tags: {} }, ctx);\n expect(conn.userModes.wallops).toBe(false);\n });\n\n it('toggles +s / -s (server notices)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n userModeReducer(conn, { command: 'MODE', params: ['alice', '+s'], tags: {} }, ctx);\n expect(conn.userModes.serverNotices).toBe(true);\n\n userModeReducer(conn, { command: 'MODE', params: ['alice', '-s'], tags: {} }, ctx);\n expect(conn.userModes.serverNotices).toBe(false);\n });\n\n it('rejects +o via MODE with 481 ERR_NOPRIVILEGES (oper is granted via OPER only)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+o'], tags: {} }, ctx);\n\n expect(conn.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('allows an existing oper to drop +o via -o', () => {\n const conn = makeConn();\n conn.userModes.oper = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-o'], tags: {} }, ctx);\n\n expect(conn.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':alice!alice@example.com MODE alice -o')]),\n ]);\n });\n\n it('combines multiple user mode changes into one echo', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+iw'], tags: {} }, ctx);\n\n expect(conn.userModes.invisible).toBe(true);\n expect(conn.userModes.wallops).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':alice!alice@example.com MODE alice +iw')]),\n ]);\n });\n\n it('emits 501 ERR_UMODEUNKNOWNFLAG for an unknown user mode char', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+z'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when no nick argument is supplied', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MODE :Not enough parameters')]),\n ]);\n });\n\n it('emits 502 ERR_USERSDONTMATCH when the target nick is not the connection', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['bob', '+i'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 502 alice :Cannot change mode for other users')]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(11_000);\n const ctx = makeCtx(conn, clock);\n\n userModeReducer(conn, { command: 'MODE', params: ['alice', '+i'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(11_000);\n });\n});\n\n// ============================================================================\n// userModeReducer — read-only mode `S` (TLS connected)\n// ============================================================================\n\ndescribe('userModeReducer — read-only mode `S` (TLS connected)', () => {\n it('emits 221 with +S when the connection has the tls user mode set', () => {\n const conn = makeConn();\n conn.userModes.tls = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 221 alice +S')]),\n ]);\n });\n\n it('emits 221 with +iS when invisible and tls are both set', () => {\n const conn = makeConn();\n conn.userModes.invisible = true;\n conn.userModes.tls = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 221 alice +iS')]),\n ]);\n });\n\n it('rejects MODE nick +S with 501 ERR_UMODEUNKNOWNFLAG (read-only)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+S'], tags: {} }, ctx);\n\n expect(conn.userModes.tls).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),\n ]);\n });\n\n it('rejects MODE nick -S with 501 ERR_UMODEUNKNOWNFLAG even when tls is set', () => {\n const conn = makeConn();\n conn.userModes.tls = true;\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-S'], tags: {} }, ctx);\n\n // The mode stays set: a client cannot clear a transport-owned mode.\n expect(conn.userModes.tls).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),\n ]);\n });\n\n it('does not echo a MODE line when only +S was supplied (rejected, no-op)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+iS'], tags: {} }, ctx);\n\n // `i` is applied (and echoed), `S` is rejected with 501. The echo only\n // carries the applied `+i`; the 501 is emitted alongside it.\n expect(conn.userModes.invisible).toBe(true);\n expect(conn.userModes.tls).toBe(false);\n const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));\n expect(texts).toContainEqual(L(':alice!alice@example.com MODE alice +i'));\n expect(texts).toContainEqual(L(':irc.example.com 501 alice :Unknown MODE flag'));\n });\n});\n"},"tests/commands/monitor.test.ts":{"tests":[{"id":"640","name":"monitorReducer — MONITOR + (add) emits 731 RPL_MONOFFLINE for an offline nick"},{"id":"641","name":"monitorReducer — MONITOR + (add) emits 730 RPL_MONONLINE for an online nick"},{"id":"642","name":"monitorReducer — MONITOR + (add) handles a comma-separated add list, splitting online/offline into 730/731"},{"id":"643","name":"monitorReducer — MONITOR + (add) omits the 730 line when no added nick is online"},{"id":"644","name":"monitorReducer — MONITOR + (add) omits the 731 line when every added nick is online"},{"id":"645","name":"monitorReducer — MONITOR + (add) skips nicks already in the watchlist without re-emitting their status"},{"id":"646","name":"monitorReducer — MONITOR + (add) reports the registered display spelling, not the requester spelling"},{"id":"647","name":"monitorReducer — MONITOR + (add) emits 734 ERR_MONLISTFULL when adding would exceed the cap, naming the rejected nicks"},{"id":"648","name":"monitorReducer — MONITOR + (add) partially admits an oversized add list up to the cap and 734s the rest"},{"id":"649","name":"monitorReducer — MONITOR - (remove) removes nicks from the watchlist silently (no numeric output)"},{"id":"650","name":"monitorReducer — MONITOR - (remove) removes multiple comma-separated nicks"},{"id":"651","name":"monitorReducer — MONITOR - (remove) is a no-op for a nick that was not being monitored"},{"id":"652","name":"monitorReducer — MONITOR - (remove) is a no-op when no watchlist has been allocated yet"},{"id":"653","name":"monitorReducer — MONITOR - (remove) is a no-op when the target list is empty (`MONITOR -` with no args)"},{"id":"654","name":"monitorReducer — MONITOR C (clear) empties the entire watchlist silently"},{"id":"655","name":"monitorReducer — MONITOR C (clear) is a no-op when the watchlist is already empty"},{"id":"656","name":"monitorReducer — MONITOR L (list) emits 732 RPL_MONLIST per line then 733 RPL_ENDOFMONLIST"},{"id":"657","name":"monitorReducer — MONITOR L (list) emits only 733 when the watchlist is empty"},{"id":"658","name":"monitorReducer — MONITOR S (status) emits 730 for online monitored nicks and 731 for offline ones"},{"id":"659","name":"monitorReducer — MONITOR S (status) emits only 730 when every monitored nick is online"},{"id":"660","name":"monitorReducer — MONITOR S (status) emits only 731 when every monitored nick is offline"},{"id":"661","name":"monitorReducer — MONITOR S (status) emits nothing when the watchlist is empty"},{"id":"662","name":"monitorReducer — validation & state uses * as nick placeholder for unregistered connections"},{"id":"663","name":"monitorReducer — validation & state updates lastSeen to ctx.clock.now()"},{"id":"664","name":"monitorReducer — validation & state returns the same state reference"},{"id":"665","name":"monitorReducer — validation & state rejects an unknown subcommand with 461 ERR_NEEDMOREPARAMS"},{"id":"666","name":"monitorReducer — validation & state rejects MONITOR + with no nick argument via 461 ERR_NEEDMOREPARAMS"},{"id":"667","name":"monitorReducer — validation & state treats MONITOR (no sub) as 461 ERR_NEEDMOREPARAMS"},{"id":"668","name":"monitorReducer — validation & state treats MONITOR with no params array at all as 461 ERR_NEEDMOREPARAMS"}],"source":"import { describe, expect, it } from 'vitest';\nimport { monitorReducer } from '../../src/commands/monitor';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\n/** Monitor cap ceiling used in tests; mirrors the production default. */\nconst MAX_MONITOR = 30;\n\nfunction makeCtx(state: ConnectionState): Ctx {\n return buildCtx({\n serverConfig: baseServerConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst monitor = (sub: string, target?: string) =>\n ({\n command: 'MONITOR',\n params: target === undefined ? [sub] : [sub, target],\n tags: {},\n }) as const;\n\n/** Builds an online map from display nicks (auto-folds each key). */\nconst onlineMap = (...nicks: string[]): ReadonlyMap<"+"string, string> =>\n new Map(nicks.map((n) => [n.toLowerCase(), n]));\n\ndescribe('monitorReducer — MONITOR + (add)', () => {\n it('emits 731 RPL_MONOFFLINE for an offline nick', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),\n ]);\n expect(state.monitorList?.has('bob')).toBe(true);\n });\n\n it('emits 730 RPL_MONONLINE for an online nick', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),\n ]);\n expect(state.monitorList?.has('bob')).toBe(true);\n });\n\n it('handles a comma-separated add list, splitting online/offline into 730/731', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(\n onlineMap('bob', 'carol'),\n MAX_MONITOR,\n monitor('+', 'bob,carol,dave'),\n ctx,\n );\n\n // Two separate effects: 730 with online nicks, then 731 with offline.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),\n Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),\n ]);\n expect(Array.from(state.monitorList ?? []).sort()).toEqual(['bob', 'carol', 'dave']);\n });\n\n it('omits the 730 line when no added nick is online', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'dave,eve'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),\n ]);\n });\n\n it('omits the 731 line when every added nick is online', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),\n ]);\n });\n\n it('skips nicks already in the watchlist without re-emitting their status', () => {\n const state = makeState();\n state.monitorList = new Set(['bob']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n // Already-monitored nick produces no output (per ircv3 MONITOR spec).\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n });\n\n it('reports the registered display spelling, not the requester spelling', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('Bob'), MAX_MONITOR, monitor('+', 'BOB'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :Bob')]),\n ]);\n // Stored in the folded form for case-insensitive membership tests.\n expect(state.monitorList?.has('bob')).toBe(true);\n });\n\n it('emits 734 ERR_MONLISTFULL when adding would exceed the cap, naming the rejected nicks', () => {\n const state = makeState();\n // Pre-fill the list to the cap.\n state.monitorList = new Set(Array.from({ length: MAX_MONITOR }, (_, i) => `nick${i}`));\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :bob,carol`)]),\n ]);\n // Cap unchanged.\n expect(state.monitorList?.size).toBe(MAX_MONITOR);\n });\n\n it('partially admits an oversized add list up to the cap and 734s the rest', () => {\n const state = makeState();\n state.monitorList = new Set(Array.from({ length: MAX_MONITOR - 1 }, (_, i) => `nick${i}`));\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);\n\n // First nick admitted (offline) → 731; second rejected → 734.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),\n Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :carol`)]),\n ]);\n expect(state.monitorList?.has('bob')).toBe(true);\n expect(state.monitorList?.has('carol')).toBe(false);\n });\n});\n\ndescribe('monitorReducer — MONITOR - (remove)', () => {\n it('removes nicks from the watchlist silently (no numeric output)', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('-', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.has('bob')).toBe(false);\n expect(state.monitorList?.has('carol')).toBe(true);\n });\n\n it('removes multiple comma-separated nicks', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol', 'dave']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob,dave'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.has('bob')).toBe(false);\n expect(state.monitorList?.has('carol')).toBe(true);\n expect(state.monitorList?.has('dave')).toBe(false);\n });\n\n it('is a no-op for a nick that was not being monitored', () => {\n const state = makeState();\n state.monitorList = new Set(['bob']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'ghost'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.has('bob')).toBe(true);\n });\n\n it('is a no-op when no watchlist has been allocated yet', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList).toBeUndefined();\n });\n\n it('is a no-op when the target list is empty (`MONITOR -` with no args)', () => {\n const state = makeState();\n state.monitorList = new Set(['bob']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.has('bob')).toBe(true);\n });\n});\n\ndescribe('monitorReducer — MONITOR C (clear)', () => {\n it('empties the entire watchlist silently', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol', 'dave']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.size).toBe(0);\n });\n\n it('is a no-op when the watchlist is already empty', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n expect(state.monitorList?.size).toBe(0);\n });\n});\n\ndescribe('monitorReducer — MONITOR L (list)', () => {\n it('emits 732 RPL_MONLIST per line then 733 RPL_ENDOFMONLIST', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);\n\n // Nicks are listed in their stored display form (which is folded, so\n // lowercase here). Order is the Set's insertion order.\n const sends = out.effects.map((e) => (e as { lines: RawLine[] }).lines[0]?.text);\n expect(sends).toContain(':irc.example.com 732 alice :bob');\n expect(sends).toContain(':irc.example.com 732 alice :carol');\n expect(sends[sends.length - 1]).toBe(':irc.example.com 733 alice :End of MONITOR list');\n });\n\n it('emits only 733 when the watchlist is empty', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 733 alice :End of MONITOR list')]),\n ]);\n });\n});\n\ndescribe('monitorReducer — MONITOR S (status)', () => {\n it('emits 730 for online monitored nicks and 731 for offline ones', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol', 'dave']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),\n Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),\n ]);\n });\n\n it('emits only 730 when every monitored nick is online', () => {\n const state = makeState();\n state.monitorList = new Set(['bob', 'carol']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),\n ]);\n });\n\n it('emits only 731 when every monitored nick is offline', () => {\n const state = makeState();\n state.monitorList = new Set(['dave', 'eve']);\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),\n ]);\n });\n\n it('emits nothing when the watchlist is empty', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n });\n});\n\ndescribe('monitorReducer — validation & state', () => {\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 731 * :bob')]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);\n\n expect(state.lastSeen).toBe(5_000);\n });\n\n it('returns the same state reference', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);\n\n expect(out.state).toBe(state);\n });\n\n it('rejects an unknown subcommand with 461 ERR_NEEDMOREPARAMS', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('Z'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Unknown command')]),\n ]);\n });\n\n it('rejects MONITOR + with no nick argument via 461 ERR_NEEDMOREPARAMS', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),\n ]);\n });\n\n it('treats MONITOR (no sub) as 461 ERR_NEEDMOREPARAMS', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = monitorReducer(new Map(), MAX_MONITOR, monitor(''), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),\n ]);\n });\n\n it('treats MONITOR with no params array at all as 461 ERR_NEEDMOREPARAMS', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n // Mirrors the parser shape for a bare `MONITOR` with no subcommand.\n const out = monitorReducer(new Map(), MAX_MONITOR, { params: [] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),\n ]);\n });\n});\n"},"tests/commands/motd.test.ts":{"tests":[{"id":"669","name":"motdReducer — success emits 375, one 372 per MOTD line, then 376 for a 3-line MOTD"},{"id":"670","name":"motdReducer — success emits exactly one 372 for a single-line MOTD"},{"id":"671","name":"motdReducer — success produces exactly one Send effect to the requesting connection"},{"id":"672","name":"motdReducer — success reads MOTD lines fresh from the provider on each invocation"},{"id":"673","name":"motdReducer — empty MOTD emits a single 422 ERR_NOMOTD when the provider has no lines"},{"id":"674","name":"motdReducer — empty MOTD emits 422 when using the EmptyMotdProvider constant"},{"id":"675","name":"motdReducer — unregistered addresses the reply to * when the connection has no nick"},{"id":"676","name":"motdReducer — line splitting splits a content line longer than the 372 budget into multiple 372 replies"},{"id":"677","name":"motdReducer — line splitting never emits a wire line longer than 510 chars (512 minus CR-LF)"},{"id":"678","name":"motdReducer — line splitting still emits the 375 and 376 framing lines around split 372 chunks"},{"id":"679","name":"motdReducer — line splitting emits a single empty 372 chunk for an empty MOTD line"},{"id":"680","name":"motdReducer — line splitting still emits one 372 per line when server+nick exhaust the 372 budget"},{"id":"681","name":"motdReducer — state updates connection lastSeen to ctx.clock.now()"},{"id":"682","name":"motdReducer — state does not mutate any connection field other than lastSeen"},{"id":"683","name":"motdReducer — state returns the same state reference (mutation permitted, no copy)"},{"id":"684","name":"motdReducer — case-insensitivity accepts a lower-case command token"},{"id":"685","name":"motdReducer — case-insensitivity accepts a mixed-case command token"}],"source":"import { describe, expect, it } from 'vitest';\nimport { motdReducer } from '../../src/commands/motd';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, type MotdProvider, StaticMotdProvider } from '../../src/ports';\nimport { FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n state: ConnectionState,\n motd: MotdProvider = new StaticMotdProvider([]),\n clock = new FakeClock(1_000),\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n connection: state,\n motd,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// motdReducer — success path\n// ============================================================================\n\ndescribe('motdReducer — success', () => {\n it('emits 375, one 372 per MOTD line, then 376 for a 3-line MOTD', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['line one', 'line two', 'line three']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 375 alice :- irc.example.com Message of the day -'),\n L(':irc.example.com 372 alice :- line one'),\n L(':irc.example.com 372 alice :- line two'),\n L(':irc.example.com 372 alice :- line three'),\n L(':irc.example.com 376 alice :End of MOTD command'),\n ]),\n ]);\n });\n\n it('emits exactly one 372 for a single-line MOTD', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['only line']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send).toBeDefined();\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n const codes = send.lines.map((l) => l.text.split(' ')[1]);\n expect(codes).toEqual(['375', '372', '376']);\n }\n });\n\n it('produces exactly one Send effect to the requesting connection', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['a', 'b']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Send');\n if (out.effects[0]?.tag === 'Send') {\n expect(out.effects[0].to).toBe('c1');\n }\n });\n\n it('reads MOTD lines fresh from the provider on each invocation', () => {\n const state = makeState();\n const provider = new StaticMotdProvider(['first']);\n const ctx = makeCtx(state, provider);\n const out1 = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n provider.setLines(['first', 'second']);\n const out2 = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const count372 = (e: EffectType | undefined): number => {\n if (e?.tag !== 'Send') return 0;\n return e.lines.filter((l) => l.text.split(' ')[1] === '372').length;\n };\n expect(count372(out1.effects[0])).toBe(1);\n expect(count372(out2.effects[0])).toBe(2);\n });\n});\n\n// ============================================================================\n// motdReducer — empty MOTD → 422\n// ============================================================================\n\ndescribe('motdReducer — empty MOTD', () => {\n it('emits a single 422 ERR_NOMOTD when the provider has no lines', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider([]));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 422 alice :MOTD File is missing')]),\n ]);\n });\n\n it('emits 422 when using the EmptyMotdProvider constant', () => {\n const state = makeState();\n const ctx = makeCtx(state, EmptyMotdProvider);\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 422 alice :MOTD File is missing')]),\n ]);\n });\n});\n\n// ============================================================================\n// motdReducer — unregistered connection\n// ============================================================================\n\ndescribe('motdReducer — unregistered', () => {\n it('addresses the reply to * when the connection has no nick', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 375 * :- irc.example.com Message of the day -'),\n L(':irc.example.com 372 * :- hi'),\n L(':irc.example.com 376 * :End of MOTD command'),\n ]),\n ]);\n });\n});\n\n// ============================================================================\n// motdReducer — long-line splitting (512-byte limit incl. tags / CR-LF)\n// ============================================================================\n\ndescribe('motdReducer — line splitting', () => {\n /**\n * Per RFC 1459 §2.3 the maximum IRC line length is 512 bytes including the\n * trailing CR-LF. The 372 wire format is:\n *\n * \":<"+"server> 372 <"+"nick> :- <"+"chunk>\\r\\n\"\n *\n * The reducer must split over-long MOTD content lines so every emitted 372\n * fits within the 512-byte budget.\n */\n it('splits a content line longer than the 372 budget into multiple 372 replies', () => {\n const state = makeState();\n // Overhead = \":\" + \"irc.example.com\" + \" 372 \" + \"alice\" + \" :- \" + \"\\r\\n\"\n // = 1 + 15 + 5 + 5 + 4 + 2 = 32 bytes.\n // So the per-372 content budget is 512 - 32 = 480 chars.\n // 500 chars of content → 2 chunks (480 + 20).\n const long = 'A'.repeat(500);\n const ctx = makeCtx(state, new StaticMotdProvider([long]));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');\n expect(rpl372).toHaveLength(2);\n // First chunk: 480 chars of A.\n expect(rpl372[0]?.text).toBe(`:irc.example.com 372 alice :- ${'A'.repeat(480)}`);\n // Second chunk: remaining 20 chars.\n expect(rpl372[1]?.text).toBe(`:irc.example.com 372 alice :- ${'A'.repeat(20)}`);\n }\n });\n\n it('never emits a wire line longer than 510 chars (512 minus CR-LF)', () => {\n const state = makeState();\n const long = 'Z'.repeat(2_000);\n const ctx = makeCtx(state, new StaticMotdProvider([long]));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n for (const line of send.lines) {\n expect(line.text.length).toBeLessThanOrEqual(510);\n }\n }\n });\n\n it('still emits the 375 and 376 framing lines around split 372 chunks', () => {\n const state = makeState();\n const long = 'B'.repeat(1_000);\n const ctx = makeCtx(state, new StaticMotdProvider([long]));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n const codes = send.lines.map((l) => l.text.split(' ')[1]);\n expect(codes[0]).toBe('375');\n expect(codes[codes.length - 1]).toBe('376');\n // Every middle code is a 372.\n for (let i = 1; i <"+" codes.length - 1; i++) {\n expect(codes[i]).toBe('372');\n }\n }\n });\n\n it('emits a single empty 372 chunk for an empty MOTD line', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n // Still exactly one 372 line for the empty input line.\n const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');\n expect(rpl372).toHaveLength(1);\n expect(rpl372[0]?.text).toBe(':irc.example.com 372 alice :- ');\n }\n });\n\n it('still emits one 372 per line when server+nick exhaust the 372 budget', () => {\n // Drive `maxMotdContentLen` to <"+"= 0 by giving the connection a nick so long\n // that the 372 line prefix exceeds 510 bytes. splitForMotd then returns\n // [''] per input line — the reducer still emits one 372 per MOTD line,\n // never silently dropping content.\n const state = makeState();\n state.nick = 'a'.repeat(600);\n const ctx = makeCtx(state, new StaticMotdProvider(['hello']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n const rpl372 = send.lines.filter((l) => l.text.split(' ')[1] === '372');\n expect(rpl372).toHaveLength(1);\n // Content is empty (the whole input got dropped because it cannot fit\n // at all) — but the empty-chunk path was still taken.\n expect(rpl372[0]?.text.endsWith(':- ')).toBe(true);\n }\n });\n});\n\n// ============================================================================\n// motdReducer — state side-effects\n// ============================================================================\n\ndescribe('motdReducer — state', () => {\n it('updates connection lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n expect(state.lastSeen).toBe(0);\n const clock = new FakeClock(7_777);\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']), clock);\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n expect(out.state.lastSeen).toBe(7_777);\n });\n\n it('does not mutate any connection field other than lastSeen', () => {\n const state = makeState();\n state.caps.add('server-time');\n state.joinedChannels.add('#foo');\n state.userModes.invisible = true;\n\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n\n expect(out.state.id).toBe('c1');\n expect(out.state.nick).toBe('alice');\n expect(out.state.user).toBe('alice');\n expect(out.state.host).toBe('example.com');\n expect(out.state.realname).toBe('Alice');\n expect(out.state.caps).toEqual(new Set(['server-time']));\n expect(out.state.joinedChannels).toEqual(new Set(['#foo']));\n expect(out.state.userModes.invisible).toBe(true);\n expect(out.state.registration).toBe('registered');\n expect(out.state.connectedSince).toBe(0);\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']));\n const out = motdReducer(state, { command: 'MOTD', params: [], tags: {} }, ctx);\n expect(out.state).toBe(state);\n });\n});\n\n// ============================================================================\n// motdReducer — case-insensitivity\n// ============================================================================\n\ndescribe('motdReducer — case-insensitivity', () => {\n it('accepts a lower-case command token', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']));\n const out = motdReducer(state, { command: 'motd', params: [], tags: {} }, ctx);\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n\n it('accepts a mixed-case command token', () => {\n const state = makeState();\n const ctx = makeCtx(state, new StaticMotdProvider(['hi']));\n const out = motdReducer(state, { command: 'MoTd', params: [], tags: {} }, ctx);\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Send');\n });\n});\n"},"tests/commands/names.test.ts":{"tests":[{"id":"686","name":"namesReducer — success emits 353 RPL_NAMREPLY with the member list and 366 RPL_ENDOFNAMES"},{"id":"687","name":"namesReducer — success emits a 353 with an empty names list when the channel has no members"},{"id":"688","name":"namesReducer — success uses * sigil in 353 when the channel is +s (secret)"},{"id":"689","name":"namesReducer — success uses @ sigil in 353 when the channel is +p (private)"},{"id":"690","name":"namesReducer — success shows only the highest-priority prefix when multi-prefix is not negotiated"},{"id":"691","name":"namesReducer — success shows all applicable prefixes when multi-prefix is negotiated"},{"id":"692","name":"namesReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"693","name":"namesReducer — success returns the same state reference (read-only reducer)"},{"id":"694","name":"namesReducer — rejections emits 461 ERR_NEEDMOREPARAMS only when no channel is supplied AND the reducer is invoked directly"},{"id":"695","name":"namesReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix"},{"id":"696","name":"namesReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma"},{"id":"697","name":"namesReducer — rejections emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"698","name":"namesReducer — rejections emits 442 ERR_NOTONCHANNEL when listing a +s channel the connection has not joined"},{"id":"699","name":"namesReducer — rejections emits 442 ERR_NOTONCHANNEL when listing a +p channel the connection has not joined"},{"id":"700","name":"namesReducer — rejections allows a member to list a +s channel"},{"id":"701","name":"namesReducer — rejections allows a non-member to list a regular (non-+s, non-+p) channel"},{"id":"702","name":"namesReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"},{"id":"703","name":"namesReducer — rejections uses * in error numeric replies when the connection has no nick (defensive)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { namesReducer } from '../../src/commands/names';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// namesReducer — success\n// ============================================================================\n\ndescribe('namesReducer — success', () => {\n it('emits 353 RPL_NAMREPLY with the member list and 366 RPL_ENDOFNAMES', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', true);\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 353 alice = #foo :@alice bob'),\n L(':irc.example.com 366 alice #foo :End of /NAMES list.'),\n ]),\n ]);\n });\n\n it('emits a 353 with an empty names list when the channel has no members', () => {\n const chan = makeChan('#foo');\n // alice is a member so the visibility check passes, but no other members.\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 353 alice = #foo :alice'),\n L(':irc.example.com 366 alice #foo :End of /NAMES list.'),\n ]),\n ]);\n });\n\n it('uses * sigil in 353 when the channel is +s (secret)', () => {\n const chan = makeChan('#foo');\n chan.modes.secret = true;\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice * #foo :alice');\n }\n });\n\n it('uses @ sigil in 353 when the channel is +p (private)', () => {\n const chan = makeChan('#foo');\n chan.modes.private = true;\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice @ #foo :alice');\n }\n });\n\n it('shows only the highest-priority prefix when multi-prefix is not negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true, true); // op AND voice\n addMember(chan, 'c3', 'carol', false, true); // voiced but NOT op\n const conn = makeConn();\n const ctx = makeCtx(conn);\n // No multi-prefix cap.\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n // bob has both @ and + but only @ is shown without multi-prefix.\n // carol has only +; that is shown as her highest prefix.\n // Order matches roster iteration (insertion order); assert substrings.\n const namesLine = send.lines[0]?.text ?? '';\n expect(namesLine).toContain('@bob');\n expect(namesLine).not.toContain('+bob');\n expect(namesLine).toContain('+carol');\n expect(namesLine).toContain('alice');\n }\n });\n\n it('shows all applicable prefixes when multi-prefix is negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true, true); // op AND voice\n const conn = makeConn();\n conn.caps.add('multi-prefix');\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n // bob has both @ and +; both shown with multi-prefix.\n const namesLine = send.lines[0]?.text ?? '';\n expect(namesLine).toContain('@+bob');\n expect(namesLine).toContain('alice');\n }\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(4_200);\n const ctx = makeCtx(conn, clock);\n\n namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(4_200);\n });\n\n it('returns the same state reference (read-only reducer)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n });\n});\n\n// ============================================================================\n// namesReducer — rejections\n// ============================================================================\n\ndescribe('namesReducer — rejections', () => {\n it('emits 461 ERR_NEEDMOREPARAMS only when no channel is supplied AND the reducer is invoked directly', () => {\n // The reducer is invoked per-channel by the actor layer; if it is invoked\n // with no params at all that is a programming error in the actor layer,\n // but we still emit 461 for safety.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice NAMES :Not enough parameters')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {\n const chan = makeChan('foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo,bar'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: [''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when listing a +s channel the connection has not joined', () => {\n const chan = makeChan('#secret');\n chan.modes.secret = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #secret :You're not on that channel\")]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when listing a +p channel the connection has not joined', () => {\n const chan = makeChan('#priv');\n chan.modes.private = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#priv'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #priv :You're not on that channel\")]),\n ]);\n });\n\n it('allows a member to list a +s channel', () => {\n const chan = makeChan('#secret');\n chan.modes.secret = true;\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice * #secret :alice bob');\n }\n });\n\n it('allows a non-member to list a regular (non-+s, non-+p) channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toBe(':irc.example.com 353 alice = #foo :bob');\n }\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 353 * = #foo :alice'),\n L(':irc.example.com 366 * #foo :End of /NAMES list.'),\n ]),\n ]);\n });\n\n it('uses * in error numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#secret');\n chan.modes.secret = true;\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = namesReducer(chan, { command: 'NAMES', params: ['#secret'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 * #secret :You're not on that channel\")]),\n ]);\n });\n});\n"},"tests/commands/oper.test.ts":{"tests":[{"id":"704","name":"operReducer — successful authentication sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials"},{"id":"705","name":"operReducer — successful authentication matches the second configured credential pair"},{"id":"706","name":"operReducer — successful authentication returns the same state reference (mutation permitted, no copy)"},{"id":"707","name":"operReducer — successful authentication updates connection lastSeen to ctx.clock.now()"},{"id":"708","name":"operReducer — successful authentication accepts a lower-case command token"},{"id":"709","name":"operReducer — already an operator (graceful no-op) re-emits 381 without error when the connection is already oper"},{"id":"710","name":"operReducer — already an operator (graceful no-op) does not require correct credentials when already oper"},{"id":"711","name":"operReducer — wrong credentials emits 464 ERR_PASSWDMISMATCH for a wrong password"},{"id":"712","name":"operReducer — wrong credentials emits 464 ERR_PASSWDMISMATCH for an unknown operator name"},{"id":"713","name":"operReducer — no credentials configured emits 491 ERR_NOOPERHOST when operCreds is absent"},{"id":"714","name":"operReducer — no credentials configured emits 491 ERR_NOOPERHOST when operCreds is an empty array"},{"id":"715","name":"operReducer — missing parameters emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied"},{"id":"716","name":"operReducer — missing parameters emits 461 ERR_NEEDMOREPARAMS when only the name is supplied"},{"id":"717","name":"operReducer — missing parameters uses \"*\" as the nick in 461 when the connection has no nick yet"},{"id":"718","name":"matchOperCred returns true when a credential pair matches exactly"},{"id":"719","name":"matchOperCred returns false when the password does not match"},{"id":"720","name":"matchOperCred returns false when no user matches"},{"id":"721","name":"matchOperCred returns false for an empty credential list"},{"id":"722","name":"matchOperCred does a case-sensitive comparison of both fields"}],"source":"import { describe, expect, it } from 'vitest';\nimport { matchOperCred, operReducer } from '../../src/commands/oper';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nconst serverConfigWithCreds: ServerConfig = {\n ...baseServerConfig,\n operCreds: [\n { user: 'alice', password: 'secret' },\n { user: 'bob', password: 'hunter2' },\n ],\n};\n\nfunction makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWithCreds): Ctx {\n return buildCtx({\n serverConfig: config,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst oper = (name?: string, password?: string) =>\n ({\n command: 'OPER',\n params: name === undefined ? [] : password === undefined ? [name] : [name, password],\n tags: {},\n }) as const;\n\ndescribe('operReducer — successful authentication', () => {\n it('sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state.userModes.oper).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),\n ]);\n });\n\n it('matches the second configured credential pair', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('bob', 'hunter2'), ctx);\n\n expect(out.state.userModes.oper).toBe(true);\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state).toBe(state);\n });\n\n it('updates connection lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n expect(state.lastSeen).toBe(0);\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state.lastSeen).toBe(5_000);\n });\n\n it('accepts a lower-case command token', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, { command: 'oper', params: ['alice', 'secret'], tags: {} }, ctx);\n\n expect(out.state.userModes.oper).toBe(true);\n });\n});\n\ndescribe('operReducer — already an operator (graceful no-op)', () => {\n it('re-emits 381 without error when the connection is already oper', () => {\n const state = makeState();\n state.userModes.oper = true;\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state.userModes.oper).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),\n ]);\n });\n\n it('does not require correct credentials when already oper', () => {\n const state = makeState();\n state.userModes.oper = true;\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'wrong'), ctx);\n\n expect(out.state.userModes.oper).toBe(true);\n expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);\n // Critical: never a 464 for an already-oper connection.\n for (const e of out.effects) {\n if (e.tag === 'Send') for (const line of e.lines) expect(line.text).not.toContain(' 464 ');\n }\n });\n});\n\ndescribe('operReducer — wrong credentials', () => {\n it('emits 464 ERR_PASSWDMISMATCH for a wrong password', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice', 'wrong'), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),\n ]);\n });\n\n it('emits 464 ERR_PASSWDMISMATCH for an unknown operator name', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('mallory', 'whatever'), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),\n ]);\n });\n});\n\ndescribe('operReducer — no credentials configured', () => {\n it('emits 491 ERR_NOOPERHOST when operCreds is absent', () => {\n const state = makeState();\n const ctx = makeCtx(state, baseServerConfig);\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),\n ]);\n });\n\n it('emits 491 ERR_NOOPERHOST when operCreds is an empty array', () => {\n const state = makeState();\n const ctx = makeCtx(state, { ...baseServerConfig, operCreds: [] });\n\n const out = operReducer(state, oper('alice', 'secret'), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);\n for (const e of out.effects) {\n if (e.tag === 'Send') for (const line of e.lines) expect(line.text).toContain(' 491 ');\n }\n });\n});\n\ndescribe('operReducer — missing parameters', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper(), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),\n ]);\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when only the name is supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper('alice'), ctx);\n\n expect(out.state.userModes.oper).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),\n ]);\n });\n\n it('uses \"*\" as the nick in 461 when the connection has no nick yet', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = operReducer(state, oper(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * OPER :Not enough parameters')]),\n ]);\n });\n});\n\ndescribe('matchOperCred', () => {\n it('returns true when a credential pair matches exactly', () => {\n const creds = [\n { user: 'alice', password: 'secret' },\n { user: 'bob', password: 'hunter2' },\n ];\n expect(matchOperCred(creds, 'bob', 'hunter2')).toBe(true);\n });\n\n it('returns false when the password does not match', () => {\n const creds = [{ user: 'alice', password: 'secret' }];\n expect(matchOperCred(creds, 'alice', 'nope')).toBe(false);\n });\n\n it('returns false when no user matches', () => {\n const creds = [{ user: 'alice', password: 'secret' }];\n expect(matchOperCred(creds, 'mallory', 'secret')).toBe(false);\n });\n\n it('returns false for an empty credential list', () => {\n expect(matchOperCred([], 'alice', 'secret')).toBe(false);\n });\n\n it('does a case-sensitive comparison of both fields', () => {\n const creds = [{ user: 'alice', password: 'secret' }];\n expect(matchOperCred(creds, 'Alice', 'secret')).toBe(false);\n expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);\n });\n});\n"},"tests/commands/part.test.ts":{"tests":[{"id":"723","name":"partReducer — success broadcasts PART with reason to the channel and removes the parter from the roster"},{"id":"724","name":"partReducer — success broadcasts PART without a trailing reason when none is supplied"},{"id":"725","name":"partReducer — success treats an empty reason param the same as no reason param"},{"id":"726","name":"partReducer — success includes the parting connection in the broadcast (no except)"},{"id":"727","name":"partReducer — success removes the channel from the connection joinedChannels set (cross-authority mutation)"},{"id":"728","name":"partReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"729","name":"partReducer — success returns the same state reference (mutation permitted, no copy)"},{"id":"730","name":"partReducer — success falls back to ? as the PART source when the connection has no nick (defensive)"},{"id":"731","name":"partReducer — rejections emits 461 ERR_NEEDMOREPARAMS when no channel is supplied"},{"id":"732","name":"partReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix"},{"id":"733","name":"partReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma"},{"id":"734","name":"partReducer — rejections emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"735","name":"partReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name exceeding the length cap"},{"id":"736","name":"partReducer — rejections emits 442 ERR_NOTONCHANNEL when the connection is not on the channel"},{"id":"737","name":"partReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { partReducer } from '../../src/commands/part';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\n/** Adds a member to the channel roster. */\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// partReducer — success path\n// ============================================================================\n\ndescribe('partReducer — success', () => {\n it('broadcasts PART with reason to the channel and removes the parter from the roster', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo', 'leaving'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo :leaving')]),\n Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c1' }] }),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('broadcasts PART without a trailing reason when none is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),\n );\n });\n\n it('treats an empty reason param the same as no reason param', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo', ''], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast('#foo', [L(':alice!alice@example.com PART #foo')]),\n );\n });\n\n it('includes the parting connection in the broadcast (no except)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects.find(\n (e): e is Extract<"+"EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',\n );\n expect(broadcast?.except).toBeUndefined();\n });\n\n it('removes the channel from the connection joinedChannels set (cross-authority mutation)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n conn.joinedChannels.add('#bar');\n const ctx = makeCtx(conn);\n\n partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.joinedChannels.has('#foo')).toBe(false);\n expect(conn.joinedChannels.has('#bar')).toBe(true);\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const clock = new FakeClock(7_500);\n const ctx = makeCtx(conn, clock);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n expect(conn.lastSeen).toBe(7_500);\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.state).toBe(chan);\n });\n\n it('falls back to ? as the PART source when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n // Roster entry with a stale nick but the connection itself has no nick.\n addMember(chan, 'c1', '?');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(Effect.broadcast('#foo', [L(':? PART #foo')]));\n });\n});\n\n// ============================================================================\n// partReducer — rejections\n// ============================================================================\n\ndescribe('partReducer — rejections', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice PART :Not enough parameters')]),\n ]);\n expect(out.state.members.has('c1')).toBe(true);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {\n const chan = makeChan('foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo,bar'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: [''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name exceeding the length cap', () => {\n const longName = `#${'a'.repeat(50)}`;\n const chan = makeChan(longName);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: [longName], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com 403 alice ${longName} :No such channel`)]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when the connection is not on the channel', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n expect(out.state.members.has('c1')).toBe(false);\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = partReducer(chan, { command: 'PART', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * PART :Not enough parameters')]),\n ]);\n });\n});\n"},"tests/commands/pre-away.test.ts":{"tests":[{"id":"738","name":"pre-away constants exposes the draft/pre-away cap name"},{"id":"739","name":"nowAwayLine builds the 306 RPL_NOWAWAY line addressed to the connection nick"},{"id":"740","name":"nowAwayLine zero-pads the numeric to three digits"},{"id":"741","name":"nowAwayLine falls back to * when the connection has no nick"},{"id":"742","name":"persistAway writes the reason to the store keyed by account"},{"id":"743","name":"persistAway also sets the connection in-memory away reason"},{"id":"744","name":"persistAway overwrites a previously stored reason for the same account"},{"id":"745","name":"persistAway still sets the in-memory reason when no AwayStore is bound"},{"id":"746","name":"persistAway is a no-op on the store when the connection has no account"},{"id":"747","name":"clearPersistedAway removes the stored reason for the account"},{"id":"748","name":"clearPersistedAway also clears the connection in-memory away reason"},{"id":"749","name":"clearPersistedAway is a no-op on the store when no AwayStore is bound but still clears in-memory"},{"id":"750","name":"clearPersistedAway is a no-op on the store when the connection has no account"},{"id":"751","name":"replayPersistedAway sets the connection away reason from the persisted value"},{"id":"752","name":"replayPersistedAway leaves the connection away reason untouched when no entry exists"},{"id":"753","name":"replayPersistedAway does not overwrite an away reason the session has already set"},{"id":"754","name":"replayPersistedAway looks up the account case-insensitively"}],"source":"import { describe, expect, it } from 'vitest';\nimport {\n PRE_AWAY_CAP,\n clearPersistedAway,\n nowAwayLine,\n persistAway,\n replayPersistedAway,\n} from '../../src/commands/pre-away';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryAwayStore,\n SequentialIdFactory,\n} from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeConn(id = 'c1', account?: string): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.registration = 'registered';\n if (account !== undefined) s.account = account;\n return s;\n}\n\nfunction makeCtx(conn: ConnectionState, away?: InMemoryAwayStore): Ctx {\n return buildCtx({\n serverConfig,\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n ...(away !== undefined ? { away } : {}),\n });\n}\n\n// ============================================================================\n// constants\n// ============================================================================\n\ndescribe('pre-away constants', () => {\n it('exposes the draft/pre-away cap name', () => {\n expect(PRE_AWAY_CAP).toBe('draft/pre-away');\n });\n});\n\n// ============================================================================\n// nowAwayLine\n// ============================================================================\n\ndescribe('nowAwayLine', () => {\n it('builds the 306 RPL_NOWAWAY line addressed to the connection nick', () => {\n const conn = makeConn('c1');\n const line = nowAwayLine(conn, 'irc.example.com');\n expect(line.text).toBe(':irc.example.com 306 alice :You have been marked as being away');\n });\n\n it('zero-pads the numeric to three digits', () => {\n const conn = makeConn('c1');\n const line = nowAwayLine(conn, 'irc.example.com');\n // 306 is already 3 digits, so no padding needed; this just guards the\n // padStart call against regression for the eventual \"no-away\" reply.\n expect(line.text).toContain(' 306 ');\n });\n\n it('falls back to * when the connection has no nick', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const line = nowAwayLine(conn, 'irc.example.com');\n expect(line.text).toBe(':irc.example.com 306 * :You have been marked as being away');\n });\n});\n\n// ============================================================================\n// persistAway\n// ============================================================================\n\ndescribe('persistAway', () => {\n it('writes the reason to the store keyed by account', () => {\n const store = new InMemoryAwayStore();\n const ctx = makeCtx(makeConn('c1', 'alice'), store);\n\n persistAway(ctx, 'brb');\n\n expect(store.get('alice')).toBe('brb');\n });\n\n it('also sets the connection in-memory away reason', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn, store);\n\n persistAway(ctx, 'brb');\n\n expect(conn.away).toBe('brb');\n });\n\n it('overwrites a previously stored reason for the same account', () => {\n const store = new InMemoryAwayStore();\n const ctx = makeCtx(makeConn('c1', 'alice'), store);\n\n persistAway(ctx, 'brb');\n persistAway(ctx, 'lunch');\n\n expect(store.get('alice')).toBe('lunch');\n });\n\n it('still sets the in-memory reason when no AwayStore is bound', () => {\n const conn = makeConn('c1', 'alice');\n const ctx = makeCtx(conn); // no store\n\n persistAway(ctx, 'brb');\n\n expect(conn.away).toBe('brb');\n });\n\n it('is a no-op on the store when the connection has no account', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn('c1'); // no account\n const ctx = makeCtx(conn, store);\n\n persistAway(ctx, 'brb');\n\n expect(store.get('alice')).toBeUndefined();\n // In-memory reason still set so the connection's own session tracks it.\n expect(conn.away).toBe('brb');\n });\n});\n\n// ============================================================================\n// clearPersistedAway\n// ============================================================================\n\ndescribe('clearPersistedAway', () => {\n it('removes the stored reason for the account', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const ctx = makeCtx(makeConn('c1', 'alice'), store);\n\n clearPersistedAway(ctx);\n\n expect(store.get('alice')).toBeUndefined();\n });\n\n it('also clears the connection in-memory away reason', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const conn = makeConn('c1', 'alice');\n conn.away = 'brb';\n const ctx = makeCtx(conn, store);\n\n clearPersistedAway(ctx);\n\n expect(conn.away).toBeUndefined();\n });\n\n it('is a no-op on the store when no AwayStore is bound but still clears in-memory', () => {\n const conn = makeConn('c1', 'alice');\n conn.away = 'brb';\n const ctx = makeCtx(conn); // no store\n\n clearPersistedAway(ctx);\n\n expect(conn.away).toBeUndefined();\n });\n\n it('is a no-op on the store when the connection has no account', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const conn = makeConn('c1'); // no account\n conn.away = 'brb';\n const ctx = makeCtx(conn, store);\n\n clearPersistedAway(ctx);\n\n // Store entry survives because the connection is unidentified.\n expect(store.get('alice')).toBe('brb');\n // In-memory is still cleared so the session's view is consistent.\n expect(conn.away).toBeUndefined();\n });\n});\n\n// ============================================================================\n// replayPersistedAway\n// ============================================================================\n\ndescribe('replayPersistedAway', () => {\n it('sets the connection away reason from the persisted value', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'brb');\n const conn = makeConn();\n\n replayPersistedAway(conn, store, 'alice');\n\n expect(conn.away).toBe('brb');\n });\n\n it('leaves the connection away reason untouched when no entry exists', () => {\n const store = new InMemoryAwayStore();\n const conn = makeConn();\n\n replayPersistedAway(conn, store, 'alice');\n\n expect(conn.away).toBeUndefined();\n });\n\n it('does not overwrite an away reason the session has already set', () => {\n const store = new InMemoryAwayStore();\n store.set('alice', 'stored');\n const conn = makeConn();\n conn.away = 'session';\n\n replayPersistedAway(conn, store, 'alice');\n\n // The session's own value wins — the user may have re-set AWAY this session.\n expect(conn.away).toBe('session');\n });\n\n it('looks up the account case-insensitively', () => {\n const store = new InMemoryAwayStore();\n store.set('Alice', 'brb');\n const conn = makeConn();\n\n replayPersistedAway(conn, store, 'alice');\n\n expect(conn.away).toBe('brb');\n });\n});\n"},"tests/commands/privmsg.test.ts":{"tests":[{"id":"755","name":"isChannelTarget returns true for a # channel name"},{"id":"756","name":"isChannelTarget returns true for a & channel name"},{"id":"757","name":"isChannelTarget returns false for a nick name"},{"id":"758","name":"isChannelTarget returns false for an empty string"},{"id":"759","name":"isChannelTarget returns false for a name with a space"},{"id":"760","name":"privmsgChannelReducer — success broadcasts PRIVMSG to all channel members except the sender"},{"id":"761","name":"privmsgChannelReducer — success broadcasts a multi-word trailing parameter verbatim"},{"id":"762","name":"privmsgChannelReducer — success allows a non-member to message the channel when +n is not set (default)"},{"id":"763","name":"privmsgChannelReducer — success allows a voiced non-op to message a +m channel"},{"id":"764","name":"privmsgChannelReducer — success allows an op to message a +m channel even without voice"},{"id":"765","name":"privmsgChannelReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"766","name":"privmsgChannelReducer — success returns the same state reference (no mutation to channel state)"},{"id":"767","name":"privmsgChannelReducer — rejections emits 411 ERR_NORECIPIENT when no target is supplied"},{"id":"768","name":"privmsgChannelReducer — rejections emits 412 ERR_NOTEXTTOSEND when no text is supplied"},{"id":"769","name":"privmsgChannelReducer — rejections emits 412 ERR_NOTEXTTOSEND when the text param is empty"},{"id":"770","name":"privmsgChannelReducer — rejections emits 404 ERR_CANNOTSENDTOCHAN when a non-member sends to a +n channel"},{"id":"771","name":"privmsgChannelReducer — rejections emits 404 ERR_CANNOTSENDTOCHAN when a non-voiced non-op sends to a +m channel"},{"id":"772","name":"privmsgChannelReducer — rejections emits 404 ERR_CANNOTSENDTOCHAN when +m is set and the sender is not a member at all"},{"id":"773","name":"privmsgChannelReducer — rejections emits 404 ERR_CANNOTSENDTOCHAN when a banned member sends to the channel"},{"id":"774","name":"privmsgChannelReducer — rejections honors the ? wildcard in ban masks when checking the sender hostmask"},{"id":"775","name":"privmsgChannelReducer — rejections proceeds with the broadcast when the ban list is non-empty but no mask matches"},{"id":"776","name":"privmsgChannelReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"},{"id":"777","name":"privmsgChannelReducer — rejections falls back to ? as the PRIVMSG source when the connection has no nick (defensive)"},{"id":"778","name":"noticeChannelReducer broadcasts NOTICE to all channel members except the sender"},{"id":"779","name":"noticeChannelReducer MUST NOT produce any numeric reply when target is missing (RFC-critical)"},{"id":"780","name":"noticeChannelReducer MUST NOT produce any numeric reply when text is missing (RFC-critical)"},{"id":"781","name":"noticeChannelReducer MUST NOT produce any numeric reply when banned (RFC-critical)"},{"id":"782","name":"noticeChannelReducer MUST NOT produce any numeric reply when rejected by +n (RFC-critical)"},{"id":"783","name":"noticeChannelReducer MUST NOT produce any numeric reply when rejected by +m (RFC-critical)"},{"id":"784","name":"noticeChannelReducer still broadcasts when the sender is an op of a +m channel"},{"id":"785","name":"noticeChannelReducer proceeds with the broadcast when the ban list is non-empty but no mask matches"},{"id":"786","name":"privmsgUserReducer emits a SendToNick effect for a private PRIVMSG to an online user"},{"id":"787","name":"privmsgUserReducer emits 411 ERR_NORECIPIENT when no target is supplied"},{"id":"788","name":"privmsgUserReducer emits 412 ERR_NOTEXTTOSEND when no text is supplied"},{"id":"789","name":"privmsgUserReducer updates lastSeen to ctx.clock.now()"},{"id":"790","name":"privmsgUserReducer returns the same state reference (mutation permitted, no copy)"},{"id":"791","name":"noticeUserReducer emits a SendToNick effect for a private NOTICE to an online user"},{"id":"792","name":"noticeUserReducer MUST NOT emit any not-found reply for an offline recipient (RFC-critical)"},{"id":"793","name":"noticeUserReducer MUST NOT produce any numeric reply when target is missing (RFC-critical)"},{"id":"794","name":"noticeUserReducer MUST NOT produce any numeric reply when text is missing (RFC-critical)"},{"id":"795","name":"privmsgChannelReducer — msgid tag carries @msgid=<"+"id> on the capLines delivered to msgid-cap peers"},{"id":"796","name":"privmsgChannelReducer — msgid tag leaves the legacy lines bare so non-cap peers see no msgid tag"},{"id":"797","name":"privmsgChannelReducer — msgid tag uses the same msgid on the live capLine and the chathistory record"},{"id":"798","name":"privmsgChannelReducer — msgid tag still emits the msgid tag when chathistory is disabled (no MessageStore)"},{"id":"799","name":"privmsgChannelReducer — msgid tag echoes the msgid-decorated line to a sender with both echo-message and msgid"},{"id":"800","name":"noticeChannelReducer — msgid tag carries @msgid=<"+"id> on the capLines delivered to msgid-cap peers"},{"id":"801","name":"privmsgChannelReducer — chathistory recording records the accepted PRIVMSG into ctx.messages with a fresh msgid and time"},{"id":"802","name":"privmsgChannelReducer — chathistory recording records an accepted channel NOTICE too"},{"id":"803","name":"privmsgChannelReducer — chathistory recording does NOT record when ctx.messages is absent (chathistory disabled)"},{"id":"804","name":"privmsgChannelReducer — chathistory recording does NOT record a user-target PRIVMSG (only channel messages are persisted)"},{"id":"805","name":"privmsgChannelReducer — chathistory recording does NOT record a user-target NOTICE"},{"id":"806","name":"privmsgChannelReducer — chathistory recording does NOT record when the message is blocked by +n (no external messages)"},{"id":"807","name":"privmsgChannelReducer — chathistory recording does NOT record when the message is blocked by +m (moderated, no voice)"},{"id":"808","name":"privmsgChannelReducer — chathistory recording does NOT record when the sender is banned (+b)"},{"id":"809","name":"privmsgChannelReducer — chathistory recording uses a distinct msgid per recorded message (ctx.ids.nonce() each time)"}],"source":"import { describe, expect, it } from 'vitest';\nimport {\n isChannelTarget,\n noticeChannelReducer,\n noticeUserReducer,\n privmsgChannelReducer,\n privmsgUserReducer,\n} from '../../src/commands/privmsg';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryMessageStore,\n type MessageStore,\n SequentialIdFactory,\n type StoredMessage,\n} from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n conn: ConnectionState,\n clock = new FakeClock(1_000),\n messages?: MessageStore,\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n ...(messages !== undefined ? { messages } : {}),\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// isChannelTarget\n// ============================================================================\n\ndescribe('isChannelTarget', () => {\n it('returns true for a # channel name', () => {\n expect(isChannelTarget('#foo')).toBe(true);\n });\n\n it('returns true for a & channel name', () => {\n expect(isChannelTarget('&foo')).toBe(true);\n });\n\n it('returns false for a nick name', () => {\n expect(isChannelTarget('alice')).toBe(false);\n });\n\n it('returns false for an empty string', () => {\n expect(isChannelTarget('')).toBe(false);\n });\n\n it('returns false for a name with a space', () => {\n expect(isChannelTarget('foo bar')).toBe(false);\n });\n});\n\n// ============================================================================\n// privmsgChannelReducer — success path\n// ============================================================================\n\ndescribe('privmsgChannelReducer — success', () => {\n it('broadcasts PRIVMSG to all channel members except the sender', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n addMember(chan, 'c3', 'carol');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hello world'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com PRIVMSG #foo :hello world')],\n 'c1',\n 'msgid',\n [L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hello world')],\n ),\n ]);\n });\n\n it('broadcasts a multi-word trailing parameter verbatim', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi there everyone'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.broadcast(\n '#foo',\n [L(':alice!alice@example.com PRIVMSG #foo :hi there everyone')],\n 'c1',\n 'msgid',\n [L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi there everyone')],\n ),\n );\n });\n\n it('allows a non-member to message the channel when +n is not set (default)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('allows a voiced non-op to message a +m channel', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('allows an op to message a +m channel even without voice', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', true, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(8_800);\n const ctx = makeCtx(conn, clock);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(8_800);\n });\n\n it('returns the same state reference (no mutation to channel state)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.state).toBe(chan);\n });\n});\n\n// ============================================================================\n// privmsgChannelReducer — rejections\n// ============================================================================\n\ndescribe('privmsgChannelReducer — rejections', () => {\n it('emits 411 ERR_NORECIPIENT when no target is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(chan, { command: 'PRIVMSG', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 411 alice :No recipient given (PRIVMSG)')]),\n ]);\n });\n\n it('emits 412 ERR_NOTEXTTOSEND when no text is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),\n ]);\n });\n\n it('emits 412 ERR_NOTEXTTOSEND when the text param is empty', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', ''], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),\n ]);\n });\n\n it('emits 404 ERR_CANNOTSENDTOCHAN when a non-member sends to a +n channel', () => {\n const chan = makeChan('#foo');\n chan.modes.noExternal = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),\n ]);\n });\n\n it('emits 404 ERR_CANNOTSENDTOCHAN when a non-voiced non-op sends to a +m channel', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),\n ]);\n });\n\n it('emits 404 ERR_CANNOTSENDTOCHAN when +m is set and the sender is not a member at all', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n // +n NOT set, so the +n check passes; the +m check then rejects a non-member.\n addMember(chan, 'c2', 'bob', true, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),\n ]);\n });\n\n it('emits 404 ERR_CANNOTSENDTOCHAN when a banned member sends to the channel', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@example.com');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),\n ]);\n });\n\n it('honors the ? wildcard in ban masks when checking the sender hostmask', () => {\n const chan = makeChan('#foo');\n // Five `?` match the 5-char user segment `alice` in alice!alice@example.com.\n chan.banMasks.add('alice!?????@example.com');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 404 alice #foo :Cannot send to channel')]),\n ]);\n });\n\n it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@baddomain.example');\n chan.banMasks.add('evil!*@*');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),\n ]);\n });\n\n it('falls back to ? as the PRIVMSG source when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n // +n not set so a non-member can send; sender has no nick at all.\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':? PRIVMSG #foo :hi')], 'c1', 'msgid', [\n L('@msgid=nonce-0 :? PRIVMSG #foo :hi'),\n ]),\n ]);\n });\n});\n\n// ============================================================================\n// noticeChannelReducer\n// ============================================================================\n\ndescribe('noticeChannelReducer', () => {\n it('broadcasts NOTICE to all channel members except the sender', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'psst'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :psst')], 'c1', 'msgid', [\n L('@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :psst'),\n ]),\n ]);\n });\n\n it('MUST NOT produce any numeric reply when target is missing (RFC-critical)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(chan, { command: 'NOTICE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when text is missing (RFC-critical)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(chan, { command: 'NOTICE', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when banned (RFC-critical)', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@example.com');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when rejected by +n (RFC-critical)', () => {\n const chan = makeChan('#foo');\n chan.modes.noExternal = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when rejected by +m (RFC-critical)', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('still broadcasts when the sender is an op of a +m channel', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', true, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@baddomain.example');\n chan.banMasks.add('evil!*@*');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n});\n\n// ============================================================================\n// privmsgUserReducer\n// ============================================================================\n\ndescribe('privmsgUserReducer', () => {\n it('emits a SendToNick effect for a private PRIVMSG to an online user', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(\n conn,\n { command: 'PRIVMSG', params: ['bob', 'hi bob'], tags: {} },\n ctx,\n );\n\n // PRIVMSG always carries the 401 fallback — the dispatch layer decides\n // whether to send it based on the lookup result.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick(\n 'bob',\n 'c1',\n [L(':alice!alice@example.com PRIVMSG bob :hi bob')],\n [L(':irc.example.com 401 alice bob :No such nick/channel')],\n ),\n ]);\n });\n\n it('emits 411 ERR_NORECIPIENT when no target is supplied', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 411 alice :No recipient given (PRIVMSG)')]),\n ]);\n });\n\n it('emits 412 ERR_NOTEXTTOSEND when no text is supplied', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(12_000);\n const ctx = makeCtx(conn, clock);\n\n privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(12_000);\n });\n\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgUserReducer(\n conn,\n { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.state).toBe(conn);\n });\n});\n\n// ============================================================================\n// noticeUserReducer\n// ============================================================================\n\ndescribe('noticeUserReducer', () => {\n it('emits a SendToNick effect for a private NOTICE to an online user', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeUserReducer(\n conn,\n { command: 'NOTICE', params: ['bob', 'psst'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com NOTICE bob :psst')]),\n ]);\n });\n\n it('MUST NOT emit any not-found reply for an offline recipient (RFC-critical)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeUserReducer(\n conn,\n { command: 'NOTICE', params: ['nobody', 'hi'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('nobody', 'c1', [L(':alice!alice@example.com NOTICE nobody :hi')]),\n ]);\n });\n\n it('MUST NOT produce any numeric reply when target is missing (RFC-critical)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeUserReducer(conn, { command: 'NOTICE', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when text is missing (RFC-critical)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeUserReducer(conn, { command: 'NOTICE', params: ['bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual([]);\n });\n});\n\n// ============================================================================\n// privmsgChannelReducer — IRCv3 msgid tag (emit @msgid=<"+"id> on the live\n// fanout for cap-enabled peers; share the nonce with the chathistory record).\n// ============================================================================\n\ndescribe('privmsgChannelReducer — msgid tag', () => {\n it('carries @msgid=<"+"id> on the capLines delivered to msgid-cap peers', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n const bcast = out.effects[0];\n expect(bcast?.tag).toBe('Broadcast');\n if (bcast?.tag === 'Broadcast') {\n expect(bcast.cap).toBe('msgid');\n expect(bcast.capLines?.[0]?.text).toBe(\n '@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi',\n );\n }\n });\n\n it('leaves the legacy lines bare so non-cap peers see no msgid tag', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n const bcast = out.effects[0];\n if (bcast?.tag === 'Broadcast') {\n expect(bcast.lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hi');\n expect(bcast.lines[0]?.text).not.toContain('msgid');\n }\n });\n\n it('uses the same msgid on the live capLine and the chathistory record', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });\n expect(recorded).toHaveLength(1);\n const recordedMsgid = (recorded[0] as StoredMessage).msgid;\n\n const bcast = out.effects[0];\n if (bcast?.tag === 'Broadcast') {\n const liveText = bcast.capLines?.[0]?.text ?? '';\n expect(liveText).toContain(`@msgid=${recordedMsgid}`);\n }\n });\n\n it('still emits the msgid tag when chathistory is disabled (no MessageStore)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn); // no store\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n const bcast = out.effects[0];\n if (bcast?.tag === 'Broadcast') {\n expect(bcast.cap).toBe('msgid');\n expect(bcast.capLines?.[0]?.text).toContain('@msgid=');\n }\n });\n\n it('echoes the msgid-decorated line to a sender with both echo-message and msgid', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('echo-message');\n conn.caps.add('msgid');\n const ctx = makeCtx(conn);\n\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n\n const echo = out.effects[1];\n expect(echo?.tag).toBe('Send');\n if (echo?.tag === 'Send') {\n expect(echo.lines[0]?.text).toBe('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi');\n }\n });\n});\n\n// ============================================================================\n// noticeChannelReducer — IRCv3 msgid tag\n// ============================================================================\n\ndescribe('noticeChannelReducer — msgid tag', () => {\n it('carries @msgid=<"+"id> on the capLines delivered to msgid-cap peers', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = noticeChannelReducer(\n chan,\n { command: 'NOTICE', params: ['#foo', 'note'], tags: {} },\n ctx,\n );\n\n const bcast = out.effects[0];\n if (bcast?.tag === 'Broadcast') {\n expect(bcast.cap).toBe('msgid');\n expect(bcast.capLines?.[0]?.text).toBe(\n '@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :note',\n );\n }\n });\n});\n\n// ============================================================================\n// chathistory recording — MessageStore.record is driven from the channel\n// PRIVMSG/NOTICE reducer for every accepted message.\n// ============================================================================\n\ndescribe('privmsgChannelReducer — chathistory recording', () => {\n it('records the accepted PRIVMSG into ctx.messages with a fresh msgid and time', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(conn, clock, store);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });\n expect(recorded).toHaveLength(1);\n const entry = recorded[0] as StoredMessage | undefined;\n expect(entry).toBeDefined();\n expect(entry?.msgid).toBe('nonce-0');\n expect(entry?.time).toBe(7_700);\n expect(entry?.chan).toBe('#foo');\n expect(entry?.command).toBe('PRIVMSG');\n expect(entry?.nick).toBe('alice');\n expect(entry?.user).toBe('alice');\n expect(entry?.host).toBe('example.com');\n expect(entry?.text).toBe('hi');\n });\n\n it('records an accepted channel NOTICE too', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);\n\n noticeChannelReducer(chan, { command: 'NOTICE', params: ['#foo', 'note'], tags: {} }, ctx);\n\n const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });\n expect(recorded).toHaveLength(1);\n expect((recorded[0] as StoredMessage).command).toBe('NOTICE');\n expect((recorded[0] as StoredMessage).text).toBe('note');\n });\n\n it('does NOT record when ctx.messages is absent (chathistory disabled)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn); // no store\n\n // Should not throw and should still broadcast.\n const out = privmsgChannelReducer(\n chan,\n { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} },\n ctx,\n );\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('does NOT record a user-target PRIVMSG (only channel messages are persisted)', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n privmsgUserReducer(conn, { command: 'PRIVMSG', params: ['bob', 'hi'], tags: {} }, ctx);\n\n expect(store.targets(0, 100_000)).toEqual([]);\n });\n\n it('does NOT record a user-target NOTICE', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n noticeUserReducer(conn, { command: 'NOTICE', params: ['bob', 'hi'], tags: {} }, ctx);\n\n expect(store.targets(0, 100_000)).toEqual([]);\n });\n\n it('does NOT record when the message is blocked by +n (no external messages)', () => {\n const chan = makeChan('#foo');\n chan.modes.noExternal = true;\n // sender c1 is NOT a member\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);\n });\n\n it('does NOT record when the message is blocked by +m (moderated, no voice)', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, false); // not op, not voiced\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);\n });\n\n it('does NOT record when the sender is banned (+b)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n chan.banMasks.add('*!*@evil.com');\n const conn = makeConn('c1', 'alice');\n conn.host = 'evil.com';\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);\n });\n\n it('uses a distinct msgid per recorded message (ctx.ids.nonce() each time)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'one'], tags: {} }, ctx);\n privmsgChannelReducer(chan, { command: 'PRIVMSG', params: ['#foo', 'two'], tags: {} }, ctx);\n\n const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });\n expect(recorded.map((m) => m.msgid)).toEqual(['nonce-0', 'nonce-1']);\n });\n});\n"},"tests/commands/registration.test.ts":{"tests":[{"id":"810","name":"isValidNick accepts a simple alpha nick"},{"id":"811","name":"isValidNick accepts a single-letter nick"},{"id":"812","name":"isValidNick accepts digits after the first char"},{"id":"813","name":"isValidNick accepts underscores"},{"id":"814","name":"isValidNick accepts hyphens"},{"id":"815","name":"isValidNick accepts square brackets"},{"id":"816","name":"isValidNick accepts backslash"},{"id":"817","name":"isValidNick accepts backtick"},{"id":"818","name":"isValidNick accepts caret"},{"id":"819","name":"isValidNick accepts curly braces"},{"id":"820","name":"isValidNick accepts pipe"},{"id":"821","name":"isValidNick accepts uppercase letters"},{"id":"822","name":"isValidNick rejects a digit-first nick"},{"id":"823","name":"isValidNick rejects a hyphen-first nick"},{"id":"824","name":"isValidNick rejects an underscore-first nick"},{"id":"825","name":"isValidNick rejects an empty string"},{"id":"826","name":"isValidNick rejects spaces"},{"id":"827","name":"isValidNick rejects exclamation marks"},{"id":"828","name":"isValidNick rejects dots"},{"id":"829","name":"isValidNick rejects a nick exceeding the length cap"},{"id":"830","name":"isValidNick accepts a nick at exactly the length cap"},{"id":"831","name":"nickReducer reserves the nick and transitions to registering when user is not set"},{"id":"832","name":"nickReducer completes registration when NICK arrives after USER"},{"id":"833","name":"nickReducer completes registration with full hostmask when host is set"},{"id":"834","name":"nickReducer emits 431 when no nickname is supplied"},{"id":"835","name":"nickReducer emits 432 for a digit-first nick"},{"id":"836","name":"nickReducer emits 432 for a nick containing an invalid char"},{"id":"837","name":"nickReducer emits 432 for an overlong nick"},{"id":"838","name":"nickReducer uses the current nick (not *) in the 432 reply when partially registered"},{"id":"839","name":"nickReducer emits ChangeNick and echoes back on post-registration nick change"},{"id":"840","name":"nickReducer echoes back with partial hostmask when host is absent on nick change"},{"id":"841","name":"nickReducer still validates grammar on post-registration nick change"},{"id":"842","name":"nickReducer still emits 431 on post-registration nick change with no param"},{"id":"843","name":"nickReducer returns state unchanged when registered state has no nick (invariant violation)"},{"id":"844","name":"nickReducer records the old nick into ctx.history on a post-registration nick change"},{"id":"845","name":"nickReducer does not record history on a pre-registration NICK"},{"id":"846","name":"nickReducer does not record history on a nick change when no store is bound"},{"id":"847","name":"nickReducer records a history entry with only nick/connId when user/host/realname are absent"},{"id":"848","name":"userReducer stores user and realname and transitions to registering when nick is not set"},{"id":"849","name":"userReducer completes registration when USER arrives after NICK"},{"id":"850","name":"userReducer includes the host in the welcome hostmask when set"},{"id":"851","name":"userReducer emits 461 when fewer than four params are supplied"},{"id":"852","name":"userReducer emits 461 when no params are supplied"},{"id":"853","name":"userReducer emits 462 when already registered"},{"id":"854","name":"userReducer uses the current nick in the 461 reply when partially registered"},{"id":"855","name":"userReducer stores a realname containing spaces from the trailing param"},{"id":"856","name":"passReducer stashes the password attempt with no effects when pre-registration"},{"id":"857","name":"passReducer does not validate the password (enforcement lives in the actor layer)"},{"id":"858","name":"passReducer overwrites a previous passAttempt on repeated PASS"},{"id":"859","name":"passReducer emits 461 when no password is supplied"},{"id":"860","name":"passReducer emits 462 when already registered"},{"id":"861","name":"buildWelcomeLines produces the welcome block in the correct numeric order with MOTD content"},{"id":"862","name":"buildWelcomeLines prefixes every line with the server source"},{"id":"863","name":"buildWelcomeLines builds the 001 hostmask from nick!user@host"},{"id":"864","name":"buildWelcomeLines builds the 001 hostmask without @host when host is absent"},{"id":"865","name":"buildWelcomeLines matches the full expected welcome block for a complete connection"},{"id":"866","name":"buildWelcomeLines returns an empty array when nick is absent (defensive)"},{"id":"867","name":"buildWelcomeLines — config-driven version & created text reflects the configured serverVersion in the 002 and 004 lines"},{"id":"868","name":"buildWelcomeLines — config-driven version & created text reflects the configured createdAt string in the 003 line"},{"id":"869","name":"buildWelcomeLines — config-driven version & created text formats a numeric createdAt (epoch ms) into the 003 line"},{"id":"870","name":"buildWelcomeLines — config-driven version & created text falls back to the default version when serverVersion is omitted"},{"id":"871","name":"buildWelcomeLines — MOTD content emits one 372 per MOTD line sourced from the MotdProvider"},{"id":"872","name":"buildWelcomeLines — MOTD content frames the MOTD content with 375 start and 376 end"},{"id":"873","name":"buildWelcomeLines — MOTD content reads MOTD lines fresh from the provider on each call"},{"id":"874","name":"buildWelcomeLines — MOTD content splits over-long MOTD lines so every emitted line fits the 510-byte budget"},{"id":"875","name":"buildWelcomeLines — empty MOTD emits 422 ERR_NOMOTD instead of a placeholder when no MOTD is configured"},{"id":"876","name":"emitWelcomeIfReady returns a Send effect and transitions to registered when nick and user are set"},{"id":"877","name":"emitWelcomeIfReady returns an empty array and does not transition when nick is missing"},{"id":"878","name":"emitWelcomeIfReady returns an empty array and does not transition when user is missing"},{"id":"879","name":"emitWelcomeIfReady returns an empty array when already registered"},{"id":"880","name":"emitWelcomeIfReady — server-password gate proceeds with registration when no serverPassword is configured"},{"id":"881","name":"emitWelcomeIfReady — server-password gate proceeds with registration when serverPassword is empty (disabled)"},{"id":"882","name":"emitWelcomeIfReady — server-password gate blocks registration with 464 + Disconnect when passAttempt is missing"},{"id":"883","name":"emitWelcomeIfReady — server-password gate blocks registration with 464 + Disconnect when passAttempt is wrong"},{"id":"884","name":"emitWelcomeIfReady — server-password gate proceeds with registration when passAttempt matches serverPassword"},{"id":"885","name":"emitWelcomeIfReady — server-password gate satisfies the gate when SASL has authenticated an account (no PASS needed)"},{"id":"886","name":"emitWelcomeIfReady — server-password gate does not register when nick is missing even if password is configured"},{"id":"887","name":"emitWelcomeIfReady — server-password gate does not register when capNegotiating is true even if password is configured"},{"id":"888","name":"userReducer — server-password gate integration emits 464 + Disconnect on the completing USER when passAttempt is wrong"},{"id":"889","name":"userReducer — server-password gate integration completes registration on the completing USER when passAttempt matches"},{"id":"890","name":"emitWelcomeIfReady — hostmask cloaking leaves host untouched when cloaking is disabled"},{"id":"891","name":"emitWelcomeIfReady — hostmask cloaking replaces the real host with a deterministic cloak when enabled"},{"id":"892","name":"emitWelcomeIfReady — hostmask cloaking uses the configured cloakedSuffix when supplied"},{"id":"893","name":"emitWelcomeIfReady — hostmask cloaking cloaks the host before building the welcome hostmask so 001 shows the cloak"},{"id":"894","name":"emitWelcomeIfReady — hostmask cloaking does not cloak when host is already absent (nothing to cloak)"},{"id":"895","name":"emitWelcomeIfReady — user mode `S` (TLS connected) sets user mode `S` (tls) at registration when the transport is secure"},{"id":"896","name":"emitWelcomeIfReady — user mode `S` (TLS connected) does not set user mode `S` when the transport is not secure"},{"id":"897","name":"emitWelcomeIfReady — user mode `S` (TLS connected) does not set user mode `S` when secure is absent (default plain transport)"},{"id":"898","name":"emitWelcomeIfReady — user mode `S` (TLS connected) completes registration over TLS via the NICK reducer end-to-end"}],"source":"import { describe, expect, it } from 'vitest';\nimport { cloakHost } from '../../src/cloak';\nimport { generateIsupport } from '../../src/commands/isupport';\nimport {\n buildWelcomeLines,\n emitWelcomeIfReady,\n isValidNick,\n nickReducer,\n passReducer,\n userReducer,\n} from '../../src/commands/registration';\nimport { DEFAULT_CREATED_TEXT, DEFAULT_SERVER_VERSION } from '../../src/config';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryNickHistoryStore,\n SequentialIdFactory,\n StaticMotdProvider,\n} from '../../src/ports';\nimport type { MotdProvider } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n state: ConnectionState,\n clock = new FakeClock(1_000),\n motd: MotdProvider = EmptyMotdProvider,\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd,\n connection: state,\n });\n}\n\n/** Like {@link makeCtx} but also threads a bound nick-history store. */\nfunction makeCtxWithHistory(\n state: ConnectionState,\n history: InMemoryNickHistoryStore,\n clock = new FakeClock(1_000),\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n history,\n });\n}\n\nfunction makeState(): ConnectionState {\n return createConnection({ id: 'c1', connectedSince: 0 });\n}\n\n/** Convenience: a fully-registered-ready state with nick + user already set. */\nfunction registeredState(): ConnectionState {\n const s = makeState();\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n/** Builds the expected welcome block for the given nick/user/host. */\nfunction expectedWelcome(nick: string, user: string, host: string | undefined): RawLine[] {\n const sv = 'irc.example.com';\n const hostmask = host !== undefined ? `${nick}!${user}@${host}` : `${nick}!${user}`;\n const prefix = `:${sv}`;\n const isupport = generateIsupport(\n {\n serverName: sv,\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n },\n new Set<"+"string>(),\n nick,\n );\n return [\n L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),\n L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),\n L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),\n L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),\n ...isupport,\n L(`${prefix} 422 ${nick} :MOTD File is missing`),\n ];\n}\n\n// ============================================================================\n// isValidNick\n// ============================================================================\n\ndescribe('isValidNick', () => {\n it('accepts a simple alpha nick', () => {\n expect(isValidNick('alice', 30)).toBe(true);\n });\n\n it('accepts a single-letter nick', () => {\n expect(isValidNick('a', 30)).toBe(true);\n });\n\n it('accepts digits after the first char', () => {\n expect(isValidNick('a1b2', 30)).toBe(true);\n });\n\n it('accepts underscores', () => {\n expect(isValidNick('a_b', 30)).toBe(true);\n });\n\n it('accepts hyphens', () => {\n expect(isValidNick('a-b', 30)).toBe(true);\n });\n\n it('accepts square brackets', () => {\n expect(isValidNick('a[b]c', 30)).toBe(true);\n });\n\n it('accepts backslash', () => {\n expect(isValidNick('a\\\\b', 30)).toBe(true);\n });\n\n it('accepts backtick', () => {\n expect(isValidNick('a`b', 30)).toBe(true);\n });\n\n it('accepts caret', () => {\n expect(isValidNick('a^b', 30)).toBe(true);\n });\n\n it('accepts curly braces', () => {\n expect(isValidNick('a{b}c', 30)).toBe(true);\n });\n\n it('accepts pipe', () => {\n expect(isValidNick('a|b', 30)).toBe(true);\n });\n\n it('accepts uppercase letters', () => {\n expect(isValidNick('Alice', 30)).toBe(true);\n });\n\n it('rejects a digit-first nick', () => {\n expect(isValidNick('1alice', 30)).toBe(false);\n });\n\n it('rejects a hyphen-first nick', () => {\n expect(isValidNick('-alice', 30)).toBe(false);\n });\n\n it('rejects an underscore-first nick', () => {\n expect(isValidNick('_alice', 30)).toBe(false);\n });\n\n it('rejects an empty string', () => {\n expect(isValidNick('', 30)).toBe(false);\n });\n\n it('rejects spaces', () => {\n expect(isValidNick('a b', 30)).toBe(false);\n });\n\n it('rejects exclamation marks', () => {\n expect(isValidNick('a!b', 30)).toBe(false);\n });\n\n it('rejects dots', () => {\n expect(isValidNick('a.b', 30)).toBe(false);\n });\n\n it('rejects a nick exceeding the length cap', () => {\n expect(isValidNick('a'.repeat(31), 30)).toBe(false);\n });\n\n it('accepts a nick at exactly the length cap', () => {\n expect(isValidNick(`a${'b'.repeat(29)}`, 30)).toBe(true);\n });\n});\n\n// ============================================================================\n// nickReducer\n// ============================================================================\n\ndescribe('nickReducer', () => {\n it('reserves the nick and transitions to registering when user is not set', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);\n expect(out.state.nick).toBe('alice');\n expect(out.state.registration).toBe('registering');\n expect(out.effects).toEqual<"+"EffectType[]>([Effect.reserveNick('alice', 'c1')]);\n });\n\n it('completes registration when NICK arrives after USER', () => {\n const state = makeState();\n state.user = 'alice';\n state.realname = 'Alice';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);\n expect(out.state.nick).toBe('alice');\n expect(out.state.registration).toBe('registered');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.reserveNick('alice', 'c1'),\n Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),\n ]);\n });\n\n it('completes registration with full hostmask when host is set', () => {\n const state = makeState();\n state.user = 'alice';\n state.realname = 'Alice';\n state.host = 'example.com';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);\n expect(out.effects).toContainEqual(\n Effect.send('c1', expectedWelcome('alice', 'alice', 'example.com')),\n );\n });\n\n it('emits 431 when no nickname is supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 431 * :No nickname given')]),\n ]);\n expect(out.state.nick).toBeUndefined();\n expect(out.state.registration).toBe('pre-registration');\n });\n\n it('emits 432 for a digit-first nick', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['1alice'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 432 * 1alice :Erroneous nickname')]),\n ]);\n expect(out.state.nick).toBeUndefined();\n });\n\n it('emits 432 for a nick containing an invalid char', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['al!ce'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 432 * al!ce :Erroneous nickname')]),\n ]);\n });\n\n it('emits 432 for an overlong nick', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const longNick = `a${'b'.repeat(30)}`;\n const out = nickReducer(state, { command: 'NICK', params: [longNick], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(`:irc.example.com 432 * ${longNick} :Erroneous nickname`)]),\n ]);\n });\n\n it('uses the current nick (not *) in the 432 reply when partially registered', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['1bad'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 432 alice 1bad :Erroneous nickname')]),\n ]);\n });\n\n it('emits ChangeNick and echoes back on post-registration nick change', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n expect(out.state.nick).toBe('bob');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.changeNick('c1', 'alice', 'bob'),\n Effect.send('c1', [L(':alice!alice@example.com NICK bob')]),\n ]);\n expect(out.state.registration).toBe('registered');\n });\n\n it('echoes back with partial hostmask when host is absent on nick change', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.realname = 'Alice';\n state.registration = 'registered';\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n expect(out.effects).toContainEqual(Effect.send('c1', [L(':alice!alice NICK bob')]));\n });\n\n it('still validates grammar on post-registration nick change', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['1bad'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 432 alice 1bad :Erroneous nickname')]),\n ]);\n expect(out.state.nick).toBe('alice');\n });\n\n it('still emits 431 on post-registration nick change with no param', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 431 alice :No nickname given')]),\n ]);\n });\n\n it('returns state unchanged when registered state has no nick (invariant violation)', () => {\n const state = makeState();\n state.registration = 'registered';\n const ctx = makeCtx(state);\n const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n expect(out.effects).toEqual([]);\n expect(out.state.nick).toBeUndefined();\n });\n\n it('records the old nick into ctx.history on a post-registration nick change', () => {\n const state = registeredState();\n const clock = new FakeClock(5_000);\n const history = new InMemoryNickHistoryStore(clock);\n const ctx = makeCtxWithHistory(state, history, clock);\n\n nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n\n const entries = history.query('alice', 10);\n expect(entries).toHaveLength(1);\n expect(entries[0]?.nick).toBe('alice');\n expect(entries[0]?.connId).toBe('c1');\n expect(entries[0]?.username).toBe('alice');\n expect(entries[0]?.hostname).toBe('example.com');\n expect(entries[0]?.realname).toBe('Alice');\n expect(entries[0]?.signoffTime).toBe(5_000);\n });\n\n it('does not record history on a pre-registration NICK', () => {\n const state = makeState();\n const clock = new FakeClock(5_000);\n const history = new InMemoryNickHistoryStore(clock);\n const ctx = makeCtxWithHistory(state, history, clock);\n\n nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);\n\n expect(history.query('alice', 10)).toEqual([]);\n });\n\n it('does not record history on a nick change when no store is bound', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n\n const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n\n // No crash; the no-store path is a graceful no-op.\n expect(out.state.nick).toBe('bob');\n });\n\n it('records a history entry with only nick/connId when user/host/realname are absent', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registered';\n const clock = new FakeClock(5_000);\n const history = new InMemoryNickHistoryStore(clock);\n const ctx = makeCtxWithHistory(state, history, clock);\n\n nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);\n\n const entries = history.query('alice', 10);\n expect(entries).toHaveLength(1);\n expect(entries[0]?.nick).toBe('alice');\n expect(entries[0]?.username).toBeUndefined();\n expect(entries[0]?.hostname).toBeUndefined();\n expect(entries[0]?.realname).toBeUndefined();\n });\n});\n\n// ============================================================================\n// userReducer\n// ============================================================================\n\ndescribe('userReducer', () => {\n it('stores user and realname and transitions to registering when nick is not set', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(out.state.user).toBe('alice');\n expect(out.state.realname).toBe('Alice');\n expect(out.state.registration).toBe('registering');\n expect(out.effects).toEqual([]);\n });\n\n it('completes registration when USER arrives after NICK', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(out.state.user).toBe('alice');\n expect(out.state.realname).toBe('Alice');\n expect(out.state.registration).toBe('registered');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),\n ]);\n });\n\n it('includes the host in the welcome hostmask when set', () => {\n const state = makeState();\n state.nick = 'alice';\n state.host = 'example.com';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(out.effects).toContainEqual(\n Effect.send('c1', expectedWelcome('alice', 'alice', 'example.com')),\n );\n });\n\n it('emits 461 when fewer than four params are supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = userReducer(state, { command: 'USER', params: ['alice', '0'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * USER :Not enough parameters')]),\n ]);\n expect(out.state.user).toBeUndefined();\n expect(out.state.registration).toBe('pre-registration');\n });\n\n it('emits 461 when no params are supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = userReducer(state, { command: 'USER', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * USER :Not enough parameters')]),\n ]);\n });\n\n it('emits 462 when already registered', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['bob', '0', '*', 'Bob'], tags: {} },\n ctx,\n );\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 462 alice :You may not reregister')]),\n ]);\n expect(out.state.user).toBe('alice');\n });\n\n it('uses the current nick in the 461 reply when partially registered', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = userReducer(state, { command: 'USER', params: ['a'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice USER :Not enough parameters')]),\n ]);\n });\n\n it('stores a realname containing spaces from the trailing param', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registering';\n const ctx = makeCtx(state);\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice Wonderland'], tags: {} },\n ctx,\n );\n expect(out.state.realname).toBe('Alice Wonderland');\n });\n});\n\n// ============================================================================\n// passReducer\n// ============================================================================\n\ndescribe('passReducer', () => {\n it('stashes the password attempt with no effects when pre-registration', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = passReducer(state, { command: 'PASS', params: ['secret'], tags: {} }, ctx);\n expect(out.state.passAttempt).toBe('secret');\n expect(out.effects).toEqual([]);\n expect(out.state.registration).toBe('pre-registration');\n });\n\n it('does not validate the password (enforcement lives in the actor layer)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = passReducer(state, { command: 'PASS', params: ['wrong-password'], tags: {} }, ctx);\n expect(out.state.passAttempt).toBe('wrong-password');\n expect(out.effects).toEqual([]);\n });\n\n it('overwrites a previous passAttempt on repeated PASS', () => {\n const state = makeState();\n state.passAttempt = 'first';\n const ctx = makeCtx(state);\n const out = passReducer(state, { command: 'PASS', params: ['second'], tags: {} }, ctx);\n expect(out.state.passAttempt).toBe('second');\n });\n\n it('emits 461 when no password is supplied', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const out = passReducer(state, { command: 'PASS', params: [], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * PASS :Not enough parameters')]),\n ]);\n expect(out.state.passAttempt).toBeUndefined();\n });\n\n it('emits 462 when already registered', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const out = passReducer(state, { command: 'PASS', params: ['secret'], tags: {} }, ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 462 alice :You may not reregister')]),\n ]);\n expect(out.state.passAttempt).toBeUndefined();\n });\n});\n\n// ============================================================================\n// buildWelcomeLines\n// ============================================================================\n\ndescribe('buildWelcomeLines', () => {\n it('produces the welcome block in the correct numeric order with MOTD content', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n const lines = buildWelcomeLines(\n state,\n makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['line one', 'line two'])),\n );\n expect(lines.length).toBeGreaterThanOrEqual(8);\n const codes = lines.map((l) => l.text.split(' ')[1]);\n // First four numerics in fixed order; 005 (>=1); MOTD block last.\n expect(codes.slice(0, 4)).toEqual(['001', '002', '003', '004']);\n expect(codes[4]).toBe('005');\n expect(codes.at(-4)).toBe('375');\n expect(codes.at(-3)).toBe('372');\n expect(codes.at(-2)).toBe('372');\n expect(codes.at(-1)).toBe('376');\n });\n\n it('prefixes every line with the server source', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const lines = buildWelcomeLines(state, makeCtx(state));\n for (const line of lines) {\n expect(line.text.startsWith(':irc.example.com ')).toBe(true);\n }\n });\n\n it('builds the 001 hostmask from nick!user@host', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n const lines = buildWelcomeLines(state, makeCtx(state));\n const welcome = lines[0];\n expect(welcome).toEqual(\n L(\n ':irc.example.com 001 alice :Welcome to the ExampleNet IRC Network, alice!alice@example.com',\n ),\n );\n });\n\n it('builds the 001 hostmask without @host when host is absent', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const lines = buildWelcomeLines(state, makeCtx(state));\n const welcome = lines[0];\n expect(welcome).toEqual(\n L(':irc.example.com 001 alice :Welcome to the ExampleNet IRC Network, alice!alice'),\n );\n });\n\n it('matches the full expected welcome block for a complete connection', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n const lines = buildWelcomeLines(state, makeCtx(state));\n expect(lines).toEqual(expectedWelcome('alice', 'alice', 'example.com'));\n });\n\n it('returns an empty array when nick is absent (defensive)', () => {\n const state = makeState();\n const lines = buildWelcomeLines(state, makeCtx(state));\n expect(lines).toEqual([]);\n });\n});\n\n// ============================================================================\n// buildWelcomeLines — server version / created text driven from ServerConfig\n// ============================================================================\n\ndescribe('buildWelcomeLines — config-driven version & created text', () => {\n /** Builds a ctx whose serverConfig carries the supplied version/created. */\n function makeCtxWithMeta(\n state: ConnectionState,\n serverVersion: string | undefined,\n createdAt: string | number | undefined,\n ): Ctx {\n const cfg: ServerConfig = {\n ...serverConfig,\n ...(serverVersion !== undefined ? { serverVersion } : {}),\n ...(createdAt !== undefined ? { createdAt } : {}),\n };\n return buildCtx({\n serverConfig: cfg,\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n }\n\n function registeredConn(): ConnectionState {\n const s = makeState();\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n return s;\n }\n\n it('reflects the configured serverVersion in the 002 and 004 lines', () => {\n const ctx = makeCtxWithMeta(registeredConn(), '9.9.9', undefined);\n const lines = buildWelcomeLines(ctx.connection, ctx);\n const line002 = lines.find((l) => l.text.split(' ')[1] === '002');\n const line004 = lines.find((l) => l.text.split(' ')[1] === '004');\n expect(line002?.text).toContain('9.9.9');\n expect(line004?.text).toContain('9.9.9');\n });\n\n it('reflects the configured createdAt string in the 003 line', () => {\n const ctx = makeCtxWithMeta(registeredConn(), undefined, '2024-06-01');\n const lines = buildWelcomeLines(ctx.connection, ctx);\n const line003 = lines.find((l) => l.text.split(' ')[1] === '003');\n expect(line003?.text).toContain('2024-06-01');\n });\n\n it('formats a numeric createdAt (epoch ms) into the 003 line', () => {\n const ctx = makeCtxWithMeta(registeredConn(), undefined, 1_700_000_000_000);\n const lines = buildWelcomeLines(ctx.connection, ctx);\n const line003 = lines.find((l) => l.text.split(' ')[1] === '003');\n // 1700000000000ms → 2023-11-14T22:13:20.000Z ; the formatted text must\n // contain a human-readable rendering of that instant, not the raw number.\n expect(line003?.text).not.toContain('1700000000000');\n expect(line003?.text).toContain('2023');\n });\n\n it('falls back to the default version when serverVersion is omitted', () => {\n const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);\n const lines = buildWelcomeLines(ctx.connection, ctx);\n const line002 = lines.find((l) => l.text.split(' ')[1] === '002');\n // The hardcoded placeholder '0.2.0' must no longer appear; the default\n // is the irc-core package version (DEFAULT_SERVER_VERSION).\n expect(line002?.text).not.toContain('0.2.0');\n expect(line002?.text).toContain(DEFAULT_SERVER_VERSION);\n });\n});\n\n// ============================================================================\n// buildWelcomeLines — MOTD driven from MotdProvider\n// ============================================================================\n\ndescribe('buildWelcomeLines — MOTD content', () => {\n it('emits one 372 per MOTD line sourced from the MotdProvider', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n const lines = buildWelcomeLines(\n state,\n makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['line A', 'line B'])),\n );\n const rpl372 = lines.filter((l) => l.text.split(' ')[1] === '372');\n expect(rpl372).toHaveLength(2);\n expect(rpl372[0]).toEqual(L(':irc.example.com 372 alice :- line A'));\n expect(rpl372[1]).toEqual(L(':irc.example.com 372 alice :- line B'));\n });\n\n it('frames the MOTD content with 375 start and 376 end', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const lines = buildWelcomeLines(\n state,\n makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['hi'])),\n );\n expect(lines).toContainEqual(\n L(':irc.example.com 375 alice :- irc.example.com Message of the day -'),\n );\n expect(lines).toContainEqual(L(':irc.example.com 372 alice :- hi'));\n expect(lines).toContainEqual(L(':irc.example.com 376 alice :End of MOTD command'));\n });\n\n it('reads MOTD lines fresh from the provider on each call', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const provider = new StaticMotdProvider(['first']);\n const ctx = makeCtx(state, new FakeClock(1_000), provider);\n const first = buildWelcomeLines(state, ctx).filter((l) => l.text.split(' ')[1] === '372');\n provider.setLines(['first', 'second']);\n const second = buildWelcomeLines(state, ctx).filter((l) => l.text.split(' ')[1] === '372');\n expect(first).toHaveLength(1);\n expect(second).toHaveLength(2);\n });\n\n it('splits over-long MOTD lines so every emitted line fits the 510-byte budget', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n const long = 'A'.repeat(2_000);\n const lines = buildWelcomeLines(\n state,\n makeCtx(state, new FakeClock(1_000), new StaticMotdProvider([long])),\n );\n for (const line of lines) {\n expect(line.text.length).toBeLessThanOrEqual(510);\n }\n // The single over-long line is hard-wrapped into many 372 chunks.\n const rpl372 = lines.filter((l) => l.text.split(' ')[1] === '372');\n expect(rpl372.length).toBeGreaterThan(1);\n });\n});\n\ndescribe('buildWelcomeLines — empty MOTD', () => {\n it('emits 422 ERR_NOMOTD instead of a placeholder when no MOTD is configured', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const lines = buildWelcomeLines(state, makeCtx(state));\n const codes = lines.map((l) => l.text.split(' ')[1]);\n expect(codes.at(-1)).toBe('422');\n expect(lines).toContainEqual(L(':irc.example.com 422 alice :MOTD File is missing'));\n // No MOTD framing numerics leak through on the empty path.\n expect(codes).not.toContain('375');\n expect(codes).not.toContain('372');\n expect(codes).not.toContain('376');\n });\n});\n\n// ============================================================================\n// emitWelcomeIfReady\n// ============================================================================\n\ndescribe('emitWelcomeIfReady', () => {\n it('returns a Send effect and transitions to registered when nick and user are set', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const ctx = makeCtx(state);\n const effect = emitWelcomeIfReady(state, ctx);\n expect(effect).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);\n expect(state.registration).toBe('registered');\n });\n\n it('returns an empty array and does not transition when nick is missing', () => {\n const state = makeState();\n state.user = 'alice';\n const ctx = makeCtx(state);\n const effect = emitWelcomeIfReady(state, ctx);\n expect(effect).toEqual([]);\n expect(state.registration).toBe('pre-registration');\n });\n\n it('returns an empty array and does not transition when user is missing', () => {\n const state = makeState();\n state.nick = 'alice';\n const ctx = makeCtx(state);\n const effect = emitWelcomeIfReady(state, ctx);\n expect(effect).toEqual([]);\n expect(state.registration).toBe('pre-registration');\n });\n\n it('returns an empty array when already registered', () => {\n const state = registeredState();\n const ctx = makeCtx(state);\n const effect = emitWelcomeIfReady(state, ctx);\n expect(effect).toEqual([]);\n expect(state.registration).toBe('registered');\n });\n});\n\n// ============================================================================\n// emitWelcomeIfReady — server-password gate\n// ============================================================================\n\n/** Builds a ctx whose serverConfig has a serverPassword set. */\nfunction makeCtxWithPassword(state: ConnectionState, password: string): Ctx {\n return buildCtx({\n serverConfig: { ...serverConfig, serverPassword: password },\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\ndescribe('emitWelcomeIfReady — server-password gate', () => {\n it('proceeds with registration when no serverPassword is configured', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const ctx = makeCtx(state);\n const effects = emitWelcomeIfReady(state, ctx);\n expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);\n expect(state.registration).toBe('registered');\n });\n\n it('proceeds with registration when serverPassword is empty (disabled)', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const ctx = makeCtxWithPassword(state, '');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(state.registration).toBe('registered');\n expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);\n });\n\n it('blocks registration with 464 + Disconnect when passAttempt is missing', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(state.registration).not.toBe('registered');\n expect(effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),\n Effect.disconnect('c1', 'Bad Password'),\n ]);\n });\n\n it('blocks registration with 464 + Disconnect when passAttempt is wrong', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.passAttempt = 'wrong';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(state.registration).not.toBe('registered');\n expect(effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),\n Effect.disconnect('c1', 'Bad Password'),\n ]);\n });\n\n it('proceeds with registration when passAttempt matches serverPassword', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.passAttempt = 's3cret';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(state.registration).toBe('registered');\n expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);\n });\n\n it('satisfies the gate when SASL has authenticated an account (no PASS needed)', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.account = 'alice';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(state.registration).toBe('registered');\n expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);\n });\n\n it('does not register when nick is missing even if password is configured', () => {\n const state = makeState();\n state.user = 'alice';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(effects).toEqual([]);\n expect(state.registration).toBe('pre-registration');\n });\n\n it('does not register when capNegotiating is true even if password is configured', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.capNegotiating = true;\n const ctx = makeCtxWithPassword(state, 's3cret');\n const effects = emitWelcomeIfReady(state, ctx);\n expect(effects).toEqual([]);\n expect(state.registration).toBe('pre-registration');\n });\n});\n\n// ============================================================================\n// userReducer / nickReducer — server-password gate integration\n// ============================================================================\n\ndescribe('userReducer — server-password gate integration', () => {\n it('emits 464 + Disconnect on the completing USER when passAttempt is wrong', () => {\n const state = makeState();\n state.nick = 'alice';\n state.registration = 'registering';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(state.registration).not.toBe('registered');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),\n Effect.disconnect('c1', 'Bad Password'),\n ]);\n });\n\n it('completes registration on the completing USER when passAttempt matches', () => {\n const state = makeState();\n state.nick = 'alice';\n state.passAttempt = 's3cret';\n state.registration = 'registering';\n const ctx = makeCtxWithPassword(state, 's3cret');\n const out = userReducer(\n state,\n { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },\n ctx,\n );\n expect(state.registration).toBe('registered');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),\n ]);\n });\n});\n\n// ============================================================================\n// emitWelcomeIfReady — hostmask cloaking integration\n// ============================================================================\n\nfunction makeCtxWithCloaking(\n state: ConnectionState,\n cloaking: { enabled: boolean; secret: string; cloakedSuffix?: string },\n): Ctx {\n return buildCtx({\n serverConfig: { ...serverConfig, cloaking },\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\ndescribe('emitWelcomeIfReady — hostmask cloaking', () => {\n it('leaves host untouched when cloaking is disabled', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = '203.0.113.5';\n const ctx = makeCtxWithCloaking(state, { enabled: false, secret: 's3cret' });\n emitWelcomeIfReady(state, ctx);\n expect(state.host).toBe('203.0.113.5');\n });\n\n it('replaces the real host with a deterministic cloak when enabled', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = '203.0.113.5';\n const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });\n emitWelcomeIfReady(state, ctx);\n expect(state.host).toBe(\n cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'ExampleNet'),\n );\n expect(state.host).not.toBe('203.0.113.5');\n });\n\n it('uses the configured cloakedSuffix when supplied', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = '203.0.113.5';\n const ctx = makeCtxWithCloaking(state, {\n enabled: true,\n secret: 's3cret',\n cloakedSuffix: 'hidden.lan',\n });\n emitWelcomeIfReady(state, ctx);\n expect(state.host).toBe(\n cloakHost(\n '203.0.113.5',\n { enabled: true, secret: 's3cret', cloakedSuffix: 'hidden.lan' },\n 'ExampleNet',\n ),\n );\n expect(state.host.endsWith('.hidden.lan')).toBe(true);\n });\n\n it('cloaks the host before building the welcome hostmask so 001 shows the cloak', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = '203.0.113.5';\n const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });\n const effects = emitWelcomeIfReady(state, ctx);\n const expectedCloak = cloakHost(\n '203.0.113.5',\n { enabled: true, secret: 's3cret' },\n 'ExampleNet',\n );\n const expectedHostmask = `alice!alice@${expectedCloak}`;\n expect(effects).toEqual([\n Effect.send('c1', expectedWelcomeWithHost('alice', expectedHostmask)),\n ]);\n });\n\n it('does not cloak when host is already absent (nothing to cloak)', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });\n emitWelcomeIfReady(state, ctx);\n expect(state.host).toBeUndefined();\n });\n});\n\n/** Builds the welcome block with an explicit cloaked host (used for cloak tests). */\nfunction expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {\n const sv = 'irc.example.com';\n const prefix = `:${sv}`;\n const isupport = generateIsupport(\n {\n serverName: sv,\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n },\n new Set<"+"string>(),\n nick,\n );\n return [\n L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),\n L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),\n L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),\n L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),\n ...isupport,\n L(`${prefix} 422 ${nick} :MOTD File is missing`),\n ];\n}\n\n// ============================================================================\n// emitWelcomeIfReady — user mode `S` (TLS connected)\n// ============================================================================\n\ndescribe('emitWelcomeIfReady — user mode `S` (TLS connected)', () => {\n it('sets user mode `S` (tls) at registration when the transport is secure', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n state.realname = 'Alice';\n state.secure = true;\n const ctx = makeCtx(state);\n\n emitWelcomeIfReady(state, ctx);\n\n expect(state.registration).toBe('registered');\n expect(state.userModes.tls).toBe(true);\n });\n\n it('does not set user mode `S` when the transport is not secure', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n state.realname = 'Alice';\n state.secure = false;\n const ctx = makeCtx(state);\n\n emitWelcomeIfReady(state, ctx);\n\n expect(state.registration).toBe('registered');\n expect(state.userModes.tls).toBe(false);\n });\n\n it('does not set user mode `S` when secure is absent (default plain transport)', () => {\n const state = makeState();\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n state.realname = 'Alice';\n const ctx = makeCtx(state);\n\n emitWelcomeIfReady(state, ctx);\n\n expect(state.registration).toBe('registered');\n expect(state.userModes.tls).toBe(false);\n });\n\n it('completes registration over TLS via the NICK reducer end-to-end', () => {\n const state = makeState();\n state.user = 'alice';\n state.realname = 'Alice';\n state.registration = 'registering';\n state.secure = true;\n const ctx = makeCtx(state);\n\n const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);\n\n expect(out.state.registration).toBe('registered');\n expect(out.state.userModes.tls).toBe(true);\n });\n});\n"},"tests/commands/rehash.test.ts":{"tests":[{"id":"899","name":"rehashReducer — oper gating emits 382 RPL_REHASHING and signals reload when the caller is oper"},{"id":"900","name":"rehashReducer — oper gating emits 481 ERR_NOPRIVILEGES and does not signal reload for a non-oper"},{"id":"901","name":"rehashReducer — oper gating accepts a lower-case command token"},{"id":"902","name":"rehashReducer — state handling returns the same state reference (mutation permitted, no copy)"},{"id":"903","name":"rehashReducer — state handling updates connection lastSeen to ctx.clock.now()"},{"id":"904","name":"rehashReducer — state handling also updates lastSeen for the non-oper rejection path"},{"id":"905","name":"rehashReducer — state handling uses \"*\" as the nick in 382 when the connection has no nick"},{"id":"906","name":"rehashReducer — state handling uses \"*\" as the nick in 481 when an unregistered non-oper has no nick"},{"id":"907","name":"rehashReducer — state handling reflects the configured source label in the 382 trailing text"},{"id":"908","name":"rehash formatting helpers buildRehashTrailing composes the success phrase from the source label"},{"id":"909","name":"rehash formatting helpers buildRehashTrailing appends the error suffix when supplied"},{"id":"910","name":"rehash formatting helpers rehashLine renders the full :server 382 nick :trailing wire line"},{"id":"911","name":"rehash formatting helpers rehashLine renders the error-suffixed line for the failure path"}],"source":"import { describe, expect, it } from 'vitest';\nimport { buildRehashTrailing, rehashLine, rehashReducer } from '../../src/commands/rehash';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport type { IrcMessage } from '../../src/protocol/messages';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(state: ConnectionState, config: ServerConfig = baseServerConfig): Ctx {\n return buildCtx({\n serverConfig: config,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(oper = false): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n s.userModes.oper = oper;\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst rehash = (): IrcMessage => ({ command: 'REHASH', params: [], tags: {} });\n\ndescribe('rehashReducer — oper gating', () => {\n it('emits 382 RPL_REHASHING and signals reload when the caller is oper', () => {\n const state = makeState(true);\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(out.shouldReload).toBe(true);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 382 alice :Rehashing from KV')]),\n ]);\n });\n\n it('emits 481 ERR_NOPRIVILEGES and does not signal reload for a non-oper', () => {\n const state = makeState(false);\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(out.shouldReload).toBe(false);\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('accepts a lower-case command token', () => {\n const state = makeState(true);\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, { command: 'rehash', params: [], tags: {} }, ctx, 'KV');\n expect(out.shouldReload).toBe(true);\n });\n});\n\ndescribe('rehashReducer — state handling', () => {\n it('returns the same state reference (mutation permitted, no copy)', () => {\n const state = makeState(true);\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(out.state).toBe(state);\n });\n\n it('updates connection lastSeen to ctx.clock.now()', () => {\n const state = makeState(true);\n expect(state.lastSeen).toBe(0);\n const ctx = makeCtx(state);\n\n rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(state.lastSeen).toBe(5_000);\n });\n\n it('also updates lastSeen for the non-oper rejection path', () => {\n const state = makeState(false);\n const ctx = makeCtx(state);\n\n rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(state.lastSeen).toBe(5_000);\n });\n\n it('uses \"*\" as the nick in 382 when the connection has no nick', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n state.userModes.oper = true;\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 382 * :Rehashing from KV')]),\n ]);\n });\n\n it('uses \"*\" as the nick in 481 when an unregistered non-oper has no nick', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'KV');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 * :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('reflects the configured source label in the 382 trailing text', () => {\n const state = makeState(true);\n const ctx = makeCtx(state);\n\n const out = rehashReducer(state, rehash(), ctx, 'Secrets Manager');\n\n expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain(\n ':Rehashing from Secrets Manager',\n );\n });\n});\n\ndescribe('rehash formatting helpers', () => {\n it('buildRehashTrailing composes the success phrase from the source label', () => {\n expect(buildRehashTrailing('config file')).toBe('Rehashing from config file');\n });\n\n it('buildRehashTrailing appends the error suffix when supplied', () => {\n expect(buildRehashTrailing('KV', 'store unreachable')).toBe(\n 'Rehashing from KV failed: store unreachable',\n );\n });\n\n it('rehashLine renders the full :server 382 nick :trailing wire line', () => {\n expect(rehashLine('irc.example.com', 'alice', 'KV')).toEqual(\n L(':irc.example.com 382 alice :Rehashing from KV'),\n );\n });\n\n it('rehashLine renders the error-suffixed line for the failure path', () => {\n expect(rehashLine('irc.example.com', 'alice', 'KV', 'boom')).toEqual(\n L(':irc.example.com 382 alice :Rehashing from KV failed: boom'),\n );\n });\n});\n"},"tests/commands/sasl.test.ts":{"tests":[{"id":"912","name":"authenticateReducer — PLAIN success requests the payload with `AUTHENTICATE +` when PLAIN is selected"},{"id":"913","name":"authenticateReducer — PLAIN success records the selected mechanism on the connection"},{"id":"914","name":"authenticateReducer — PLAIN success completes with 900 and 903 when the AccountStore accepts the credentials"},{"id":"915","name":"authenticateReducer — PLAIN success records the authenticated account name on the connection"},{"id":"916","name":"authenticateReducer — PLAIN success seeds lastReadMarkers from the ReadMarkerStore at identify"},{"id":"917","name":"authenticateReducer — PLAIN success does not seed markers for a different account"},{"id":"918","name":"authenticateReducer — PLAIN success replays the persisted away reason on identify (draft/pre-away)"},{"id":"919","name":"authenticateReducer — PLAIN success emits 306 RPL_NOWAWAY after identifying when an away reason is replayed"},{"id":"920","name":"authenticateReducer — PLAIN success does not replay an away reason when none is persisted"},{"id":"921","name":"authenticateReducer — PLAIN success does not replay an away reason when no AwayStore is bound"},{"id":"922","name":"authenticateReducer — PLAIN success looks up the persisted away reason case-insensitively"},{"id":"923","name":"authenticateReducer — PLAIN success clears the in-progress mechanism after a successful authentication"},{"id":"924","name":"authenticateReducer — PLAIN success forwards parsed PLAIN credentials to the AccountStore"},{"id":"925","name":"authenticateReducer — PLAIN success ignores the authorization identity field in the PLAIN payload"},{"id":"926","name":"authenticateReducer — PLAIN success targets `*` with an empty hostmask when the client has no nick yet"},{"id":"927","name":"authenticateReducer — PLAIN failure emits 904 ERR_SASLFAIL when the AccountStore rejects the credentials"},{"id":"928","name":"authenticateReducer — PLAIN failure does not record an account on failure"},{"id":"929","name":"authenticateReducer — PLAIN failure clears the in-progress mechanism after a failure"},{"id":"930","name":"authenticateReducer — no AccountStore configured emits 904 when the PLAIN payload arrives but no AccountStore is wired"},{"id":"931","name":"authenticateReducer — EXTERNAL with mTLS enters the payload phase when an mTLS provider is configured"},{"id":"932","name":"authenticateReducer — EXTERNAL with mTLS returns 908 ERR_SASLMECHS when no mTLS provider is configured"},{"id":"933","name":"authenticateReducer — EXTERNAL with mTLS succeeds (900/903) when a valid cert identity is presented"},{"id":"934","name":"authenticateReducer — EXTERNAL with mTLS clears saslMech/saslBuffer after a successful EXTERNAL"},{"id":"935","name":"authenticateReducer — EXTERNAL with mTLS fails with 904 when the provider has no identity for the connection"},{"id":"936","name":"authenticateReducer — EXTERNAL with mTLS fails with 904 when the account store rejects the identity"},{"id":"937","name":"authenticateReducer — EXTERNAL with mTLS fails with 904 when no account store is configured"},{"id":"938","name":"authenticateReducer — unknown mechanism emits 908 ERR_SASLMECHS listing the available mechanisms"},{"id":"939","name":"authenticateReducer — unknown mechanism lists PLAIN,EXTERNAL in 908 when an mTLS provider is configured"},{"id":"940","name":"authenticateReducer — unknown mechanism does not enter the payload phase for an unknown mechanism"},{"id":"941","name":"authenticateReducer — abort / already-authenticated emits 907 ERR_SASLALREADY when sent after CAP END"},{"id":"942","name":"authenticateReducer — abort / already-authenticated emits 907 when sent after a successful SASL authentication"},{"id":"943","name":"authenticateReducer — abort / already-authenticated emits 907 when no CAP negotiation is in progress"},{"id":"944","name":"authenticateReducer — client abort emits 906 ERR_SASLABORT when the client sends AUTHENTICATE *"},{"id":"945","name":"authenticateReducer — client abort clears the in-progress mechanism on client abort"},{"id":"946","name":"authenticateReducer — client abort ignores AUTHENTICATE * when no SASL exchange is in progress"},{"id":"947","name":"authenticateReducer — empty payload (`+`) treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN"},{"id":"948","name":"authenticateReducer — chunked payload buffers chunks of exactly 400 bytes and verifies on the short final chunk"},{"id":"949","name":"authenticateReducer — chunked payload rejects an oversized payload with 905 ERR_SASLTOOLONG"},{"id":"950","name":"authenticateReducer — malformed PLAIN emits 904 when the base64 payload does not decode to three NUL-separated fields"},{"id":"951","name":"authenticateReducer — malformed PLAIN emits 904 when the payload is not valid base64"},{"id":"952","name":"authenticateReducer — malformed PLAIN emits 908 when the first AUTHENTICATE frame is not a recognized mechanism"},{"id":"953","name":"authenticateReducer — casing and params accepts a lowercase mechanism name"},{"id":"954","name":"authenticateReducer — casing and params emits 461 when AUTHENTICATE is sent with no parameter"},{"id":"955","name":"authenticateReducer — casing and params targets `*` before the client has a nick"},{"id":"956","name":"authenticateReducer — CAP REQ sasl integration completes the full CAP REQ sasl → AUTHENTICATE PLAIN flow"},{"id":"957","name":"authenticateReducer — CAP REQ sasl integration advertises the base sasl cap value (PLAIN) without mTLS"}],"source":"import { describe, expect, it } from 'vitest';\nimport { getLsString } from '../../src/caps/capabilities';\nimport { capReducer } from '../../src/commands/cap';\nimport { authenticateReducer } from '../../src/commands/sasl';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport type {\n AccountStore,\n AwayStore,\n MtlsIdentityProvider,\n ReadMarkerStore,\n SaslPayload,\n SaslResult,\n} from '../../src/ports';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryAwayStore,\n InMemoryReadMarkerStore,\n SequentialIdFactory,\n} from '../../src/ports';\nimport { encodeBase64 } from '../../src/protocol/base64';\nimport type { IrcMessage } from '../../src/protocol/messages';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nconst L = (text: string): RawLine => ({ text });\n\nconst SV = 'irc.example.com';\n\n/** Standard SASL-over-CAP negotiation state: cap requested, nick set. */\nfunction saslReadyState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registering';\n s.capNegotiating = true;\n s.caps.add('sasl');\n return s;\n}\n\nfunction makeCtx(\n state: ConnectionState,\n accounts?: AccountStore,\n mtlsIdentity?: MtlsIdentityProvider,\n readMarkers?: ReadMarkerStore,\n away?: AwayStore,\n): Ctx {\n return buildCtx({\n serverConfig,\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n ...(accounts !== undefined ? { accounts } : {}),\n ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),\n ...(readMarkers !== undefined ? { readMarkers } : {}),\n ...(away !== undefined ? { away } : {}),\n });\n}\n\n/** A capture-and-respond fake AccountStore. */\nclass FakeAccountStore implements AccountStore {\n readonly calls: Array<"+"{ mech: string; payload: SaslPayload }> = [];\n constructor(private readonly result: SaslResult) {}\n verify(mech: string, payload: SaslPayload): SaslResult {\n this.calls.push({ mech, payload });\n return this.result;\n }\n}\n\n/** A fake MtlsIdentityProvider that returns a canned identity for one connId. */\nclass FakeMtlsIdentityProvider implements MtlsIdentityProvider {\n readonly calls: string[] = [];\n constructor(\n private readonly connId: string,\n private readonly identity: string | undefined,\n ) {}\n getIdentity(connId: string): string | undefined {\n this.calls.push(connId);\n return connId === this.connId ? this.identity : undefined;\n }\n}\n\n/** Builds the PLAIN base64 payload `authzid\\0authcid\\0password`. */\nfunction plainPayload(authcid: string, password: string, authzid = ''): string {\n return encodeBase64(`${authzid}\\0${authcid}\\0${password}`);\n}\n\nfunction authenticate(param: string): IrcMessage {\n return { command: 'AUTHENTICATE', params: [param], tags: {} };\n}\n\nfunction sendLinesOf(effect: EffectType | undefined): RawLine[] {\n if (effect === undefined) return [];\n if (effect.tag !== 'Send') return [];\n return effect.lines;\n}\n\n/** Returns the text of the first line emitted by `effect`, or `''`. */\nfunction firstLineText(effect: EffectType | undefined): string {\n return sendLinesOf(effect)[0]?.text ?? '';\n}\n\n// ============================================================================\n// AUTHENTICATE PLAIN — happy path\n// ============================================================================\n\ndescribe('authenticateReducer — PLAIN success', () => {\n it('requests the payload with `AUTHENTICATE +` when PLAIN is selected', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('PLAIN'), ctx);\n expect(out.effects).toEqual<"+"EffectType[]>([Effect.send('c1', [L('AUTHENTICATE +')])]);\n });\n\n it('records the selected mechanism on the connection', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n expect(state.saslMech).toBe('PLAIN');\n });\n\n it('completes with 900 and 903 when the AccountStore accepts the credentials', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n const lines = sendLinesOf(out.effects[0]);\n expect(lines).toEqual<"+"RawLine[]>([\n L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),\n L(`:${SV} 903 alice :SASL authentication successful`),\n ]);\n });\n\n it('records the authenticated account name on the connection', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.account).toBe('alice');\n });\n\n it('seeds lastReadMarkers from the ReadMarkerStore at identify', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const readMarkers = new InMemoryReadMarkerStore();\n readMarkers.set('alice', '#foo', 'm1');\n readMarkers.set('alice', '#bar', 'm2');\n const ctx = makeCtx(state, accounts, undefined, readMarkers);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.lastReadMarkers?.get('#foo')).toBe('m1');\n expect(state.lastReadMarkers?.get('#bar')).toBe('m2');\n });\n\n it('does not seed markers for a different account', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const readMarkers = new InMemoryReadMarkerStore();\n readMarkers.set('bob', '#foo', 'm9');\n const ctx = makeCtx(state, accounts, undefined, readMarkers);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.lastReadMarkers?.get('#foo')).toBeUndefined();\n });\n\n it('replays the persisted away reason on identify (draft/pre-away)', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const away = new InMemoryAwayStore();\n away.set('alice', 'brb');\n const ctx = makeCtx(state, accounts, undefined, undefined, away);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.away).toBe('brb');\n });\n\n it('emits 306 RPL_NOWAWAY after identifying when an away reason is replayed', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const away = new InMemoryAwayStore();\n away.set('alice', 'brb');\n const ctx = makeCtx(state, accounts, undefined, undefined, away);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));\n expect(texts.map((l) => l.text)).toContain(\n ':irc.example.com 306 alice :You have been marked as being away',\n );\n });\n\n it('does not replay an away reason when none is persisted', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const away = new InMemoryAwayStore();\n const ctx = makeCtx(state, accounts, undefined, undefined, away);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.away).toBeUndefined();\n const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));\n expect(texts.some((l) => l.text.includes('306'))).toBe(false);\n });\n\n it('does not replay an away reason when no AwayStore is bound', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts); // no away store\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.away).toBeUndefined();\n const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));\n expect(texts.some((l) => l.text.includes('306'))).toBe(false);\n });\n\n it('looks up the persisted away reason case-insensitively', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const away = new InMemoryAwayStore();\n away.set('ALICE', 'brb');\n const ctx = makeCtx(state, accounts, undefined, undefined, away);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.away).toBe('brb');\n });\n\n it('clears the in-progress mechanism after a successful authentication', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(state.saslMech).toBeUndefined();\n });\n\n it('forwards parsed PLAIN credentials to the AccountStore', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(accounts.calls).toEqual([\n { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },\n ]);\n });\n\n it('ignores the authorization identity field in the PLAIN payload', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret', 'bob')), ctx);\n\n expect(accounts.calls[0]?.payload).toEqual({\n kind: 'PLAIN',\n username: 'alice',\n password: 'secret',\n });\n });\n\n it('targets `*` with an empty hostmask when the client has no nick yet', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n state.capNegotiating = true;\n state.caps.add('sasl');\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 900 * * alice :You are now logged in as alice`),\n L(`:${SV} 903 * :SASL authentication successful`),\n ]);\n expect(state.account).toBe('alice');\n });\n});\n\n// ============================================================================\n// AUTHENTICATE PLAIN — wrong password\n// ============================================================================\n\ndescribe('authenticateReducer — PLAIN failure', () => {\n it('emits 904 ERR_SASLFAIL when the AccountStore rejects the credentials', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 904 alice :SASL authentication failed`),\n ]);\n });\n\n it('does not record an account on failure', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);\n\n expect(state.account).toBeUndefined();\n });\n\n it('clears the in-progress mechanism after a failure', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);\n\n expect(state.saslMech).toBeUndefined();\n });\n});\n\n// ============================================================================\n// AUTHENTICATE PLAIN — no AccountStore wired\n// ============================================================================\n\ndescribe('authenticateReducer — no AccountStore configured', () => {\n it('emits 904 when the PLAIN payload arrives but no AccountStore is wired', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 904 alice :SASL authentication failed`),\n ]);\n });\n});\n\n// ============================================================================\n// AUTHENTICATE EXTERNAL — mTLS-backed SASL EXTERNAL\n// ============================================================================\n\ndescribe('authenticateReducer — EXTERNAL with mTLS', () => {\n it('enters the payload phase when an mTLS provider is configured', () => {\n const state = saslReadyState();\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');\n const ctx = makeCtx(state, undefined, provider);\n const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n\n expect(state.saslMech).toBe('EXTERNAL');\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([L('AUTHENTICATE +')]);\n });\n\n it('returns 908 ERR_SASLMECHS when no mTLS provider is configured', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),\n ]);\n expect(state.saslMech).toBeUndefined();\n });\n\n it('succeeds (900/903) when a valid cert identity is presented', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');\n const ctx = makeCtx(state, accounts, provider);\n\n authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n const out = authenticateReducer(state, authenticate('+'), ctx);\n\n expect(state.account).toBe('alice');\n expect(accounts.calls).toEqual([\n { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },\n ]);\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),\n L(`:${SV} 903 alice :SASL authentication successful`),\n ]);\n });\n\n it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');\n const ctx = makeCtx(state, accounts, provider);\n\n authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n authenticateReducer(state, authenticate('+'), ctx);\n\n expect(state.saslMech).toBeUndefined();\n expect(state.saslBuffer).toBeUndefined();\n });\n\n it('fails with 904 when the provider has no identity for the connection', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const provider = new FakeMtlsIdentityProvider('c1', undefined);\n const ctx = makeCtx(state, accounts, provider);\n\n authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n const out = authenticateReducer(state, authenticate('+'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 904 alice :SASL authentication failed`),\n ]);\n expect(state.account).toBeUndefined();\n });\n\n it('fails with 904 when the account store rejects the identity', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');\n const ctx = makeCtx(state, accounts, provider);\n\n authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n const out = authenticateReducer(state, authenticate('+'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 904 alice :SASL authentication failed`),\n ]);\n });\n\n it('fails with 904 when no account store is configured', () => {\n const state = saslReadyState();\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');\n const ctx = makeCtx(state, undefined, provider);\n\n authenticateReducer(state, authenticate('EXTERNAL'), ctx);\n const out = authenticateReducer(state, authenticate('+'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 904 alice :SASL authentication failed`),\n ]);\n });\n});\n\n// ============================================================================\n// AUTHENTICATE <"+"unknown mechanism>\n// ============================================================================\n\ndescribe('authenticateReducer — unknown mechanism', () => {\n it('emits 908 ERR_SASLMECHS listing the available mechanisms', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),\n ]);\n });\n\n it('lists PLAIN,EXTERNAL in 908 when an mTLS provider is configured', () => {\n const state = saslReadyState();\n const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');\n const ctx = makeCtx(state, undefined, provider);\n const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 908 alice PLAIN,EXTERNAL :are the available SASL mechanisms`),\n ]);\n });\n\n it('does not enter the payload phase for an unknown mechanism', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n authenticateReducer(state, authenticate('CRAM-MD5'), ctx);\n expect(state.saslMech).toBeUndefined();\n });\n});\n\n// ============================================================================\n// AUTHENTICATE abort path (907)\n// ============================================================================\n\ndescribe('authenticateReducer — abort / already-authenticated', () => {\n it('emits 907 ERR_SASLALREADY when sent after CAP END', () => {\n const state = saslReadyState();\n state.capNegotiating = false;\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('PLAIN'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 907 alice :You have already authenticated using SASL`),\n ]);\n });\n\n it('emits 907 when sent after a successful SASL authentication', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n expect(state.account).toBe('alice');\n\n const out = authenticateReducer(state, authenticate('PLAIN'), ctx);\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 907 alice :You have already authenticated using SASL`),\n ]);\n });\n\n it('emits 907 when no CAP negotiation is in progress', () => {\n const state = saslReadyState();\n state.capNegotiating = false;\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('PLAIN'), ctx);\n expect(firstLineText(out.effects[0])).toContain(' 907 ');\n });\n});\n\n// ============================================================================\n// AUTHENTICATE * — client abort (906)\n// ============================================================================\n\ndescribe('authenticateReducer — client abort', () => {\n it('emits 906 ERR_SASLABORT when the client sends AUTHENTICATE *', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate('*'), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 906 alice :SASL authentication aborted`),\n ]);\n });\n\n it('clears the in-progress mechanism on client abort', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n authenticateReducer(state, authenticate('*'), ctx);\n expect(state.saslMech).toBeUndefined();\n });\n\n it('ignores AUTHENTICATE * when no SASL exchange is in progress', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('*'), ctx);\n expect(out.effects).toEqual([]);\n expect(state.saslMech).toBeUndefined();\n });\n});\n\n// ============================================================================\n// AUTHENTICATE + — empty payload marker\n// ============================================================================\n\ndescribe('authenticateReducer — empty payload (`+`)', () => {\n it('treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: false, reason: 'empty' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate('+'), ctx);\n\n expect(accounts.calls).toEqual([]);\n expect(firstLineText(out.effects[0])).toContain(' 904 ');\n expect(state.saslMech).toBeUndefined();\n });\n});\n\n// ============================================================================\n// AUTHENTICATE — multi-line (chunked) payload\n// ============================================================================\n\ndescribe('authenticateReducer — chunked payload', () => {\n it('buffers chunks of exactly 400 bytes and verifies on the short final chunk', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n const longPassword = 'p'.repeat(320);\n const payload = plainPayload('alice', longPassword);\n expect(payload.length).toBeGreaterThan(400);\n\n const first = payload.slice(0, 400);\n const rest = payload.slice(400);\n expect(rest.length).toBeGreaterThan(0);\n expect(rest.length).toBeLessThan(400);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const buffered = authenticateReducer(state, authenticate(first), ctx);\n expect(buffered.effects).toEqual([]);\n expect(state.saslMech).toBe('PLAIN');\n expect(accounts.calls).toEqual([]);\n\n authenticateReducer(state, authenticate(rest), ctx);\n expect(accounts.calls).toEqual([\n { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: longPassword } },\n ]);\n expect(state.account).toBe('alice');\n });\n\n it('rejects an oversized payload with 905 ERR_SASLTOOLONG', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);\n\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 905 alice :SASL message too long`),\n ]);\n expect(state.saslMech).toBeUndefined();\n });\n});\n\n// ============================================================================\n// AUTHENTICATE — malformed PLAIN payload\n// ============================================================================\n\ndescribe('authenticateReducer — malformed PLAIN', () => {\n it('emits 904 when the base64 payload does not decode to three NUL-separated fields', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate(encodeBase64('onlyonefield')), ctx);\n\n expect(firstLineText(out.effects[0])).toContain(' 904 ');\n expect(accounts.calls).toEqual([]);\n });\n\n it('emits 904 when the payload is not valid base64', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n authenticateReducer(state, authenticate('PLAIN'), ctx);\n const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);\n\n expect(firstLineText(out.effects[0])).toContain(' 904 ');\n expect(accounts.calls).toEqual([]);\n });\n\n it('emits 908 when the first AUTHENTICATE frame is not a recognized mechanism', () => {\n const state = saslReadyState();\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n\n expect(firstLineText(out.effects[0])).toContain(' 908 ');\n expect(accounts.calls).toEqual([]);\n });\n});\n\n// ============================================================================\n// AUTHENTICATE — casing & missing parameters\n// ============================================================================\n\ndescribe('authenticateReducer — casing and params', () => {\n it('accepts a lowercase mechanism name', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('plain'), ctx);\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([L('AUTHENTICATE +')]);\n expect(state.saslMech).toBe('PLAIN');\n });\n\n it('emits 461 when AUTHENTICATE is sent with no parameter', () => {\n const state = saslReadyState();\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, { command: 'AUTHENTICATE', params: [], tags: {} }, ctx);\n expect(sendLinesOf(out.effects[0])).toEqual<"+"RawLine[]>([\n L(`:${SV} 461 alice AUTHENTICATE :Not enough parameters`),\n ]);\n });\n\n it('targets `*` before the client has a nick', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n state.capNegotiating = true;\n state.caps.add('sasl');\n const ctx = makeCtx(state);\n const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);\n expect(firstLineText(out.effects[0])).toContain(' 908 * ');\n });\n});\n\n// ============================================================================\n// CAP REQ sasl → AUTHENTICATE integration (acceptance criterion)\n// ============================================================================\n\ndescribe('authenticateReducer — CAP REQ sasl integration', () => {\n it('completes the full CAP REQ sasl → AUTHENTICATE PLAIN flow', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n state.nick = 'alice';\n state.user = 'alice';\n state.host = 'example.com';\n state.registration = 'registering';\n const accounts = new FakeAccountStore({ ok: true, account: 'alice' });\n const ctx = makeCtx(state, accounts);\n\n capReducer(state, { command: 'CAP', params: ['REQ', 'sasl'], tags: {} }, ctx);\n expect(state.caps.has('sasl')).toBe(true);\n\n const mechOut = authenticateReducer(state, authenticate('PLAIN'), ctx);\n expect(sendLinesOf(mechOut.effects[0])).toEqual<"+"RawLine[]>([L('AUTHENTICATE +')]);\n\n const finalOut = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);\n const lines = sendLinesOf(finalOut.effects[0]);\n expect(lines.map((l) => l.text)).toEqual([\n `:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,\n `:${SV} 903 alice :SASL authentication successful`,\n ]);\n expect(state.account).toBe('alice');\n });\n\n it('advertises the base sasl cap value (PLAIN) without mTLS', () => {\n const advertised = getLsString();\n expect(advertised).toContain('sasl=PLAIN');\n expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');\n });\n});\n"},"tests/commands/server-info.test.ts":{"tests":[{"id":"958","name":"versionReducer emits 351 RPL_VERSION with the server version and name"},{"id":"959","name":"versionReducer updates lastSeen to ctx.clock.now()"},{"id":"960","name":"versionReducer uses * as nick placeholder for unregistered connections"},{"id":"961","name":"versionReducer ignores the optional server target parameter (single-server deployment)"},{"id":"962","name":"versionReducer returns the same state reference"},{"id":"963","name":"timeReducer emits 391 RPL_TIME with a human-readable timestamp from the clock"},{"id":"964","name":"timeReducer updates lastSeen to ctx.clock.now()"},{"id":"965","name":"timeReducer uses * as nick placeholder for unregistered connections"},{"id":"966","name":"timeReducer ignores the optional server target parameter"},{"id":"967","name":"adminReducer emits 256 RPL_ADMINME with the server name"},{"id":"968","name":"adminReducer updates lastSeen to ctx.clock.now()"},{"id":"969","name":"adminReducer uses * as nick placeholder for unregistered connections"},{"id":"970","name":"adminReducer ignores the optional server target parameter"},{"id":"971","name":"infoReducer emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO"},{"id":"972","name":"infoReducer includes at least one 371 line and the 374 terminator"},{"id":"973","name":"infoReducer updates lastSeen to ctx.clock.now()"},{"id":"974","name":"infoReducer uses * as nick placeholder for unregistered connections"}],"source":"import { describe, expect, it } from 'vitest';\nimport {\n adminReducer,\n infoReducer,\n timeReducer,\n versionReducer,\n} from '../../src/commands/server-info';\nimport { DEFAULT_SERVER_VERSION } from '../../src/config';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(state: ConnectionState, clockMs = 5_000): Ctx {\n return buildCtx({\n serverConfig: baseServerConfig,\n clock: new FakeClock(clockMs),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst version = (server?: string) =>\n ({ command: 'VERSION', params: server === undefined ? [] : [server], tags: {} }) as const;\n\nconst time = (server?: string) =>\n ({ command: 'TIME', params: server === undefined ? [] : [server], tags: {} }) as const;\n\nconst admin = (server?: string) =>\n ({ command: 'ADMIN', params: server === undefined ? [] : [server], tags: {} }) as const;\n\nconst info = (server?: string) =>\n ({ command: 'INFO', params: server === undefined ? [] : [server], tags: {} }) as const;\n\n// ---------------------------------------------------------------------------\n// VERSION\n// ---------------------------------------------------------------------------\n\ndescribe('versionReducer', () => {\n it('emits 351 RPL_VERSION with the server version and name', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = versionReducer(state, version(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),\n ]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state, 9_999);\n\n versionReducer(state, version(), ctx);\n\n expect(state.lastSeen).toBe(9_999);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = versionReducer(state, version(), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [\n L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),\n ]),\n );\n });\n\n it('ignores the optional server target parameter (single-server deployment)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = versionReducer(state, version('other.server'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [\n L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),\n ]),\n );\n });\n\n it('returns the same state reference', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = versionReducer(state, version(), ctx);\n\n expect(out.state).toBe(state);\n });\n});\n\n// ---------------------------------------------------------------------------\n// TIME\n// ---------------------------------------------------------------------------\n\ndescribe('timeReducer', () => {\n it('emits 391 RPL_TIME with a human-readable timestamp from the clock', () => {\n const state = makeState();\n const ctx = makeCtx(state, 1_700_000_000_000);\n\n const out = timeReducer(state, time(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),\n ]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state, 42_000);\n\n timeReducer(state, time(), ctx);\n\n expect(state.lastSeen).toBe(42_000);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state, 1_700_000_000_000);\n\n const out = timeReducer(state, time(), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 391 * irc.example.com :2023-11-14T22:13:20.000Z')]),\n );\n });\n\n it('ignores the optional server target parameter', () => {\n const state = makeState();\n const ctx = makeCtx(state, 1_700_000_000_000);\n\n const out = timeReducer(state, time('elsewhere'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [\n L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),\n ]),\n );\n });\n});\n\n// ---------------------------------------------------------------------------\n// ADMIN\n// ---------------------------------------------------------------------------\n\ndescribe('adminReducer', () => {\n it('emits 256 RPL_ADMINME with the server name', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = adminReducer(state, admin(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),\n ]);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state, 7_777);\n\n adminReducer(state, admin(), ctx);\n\n expect(state.lastSeen).toBe(7_777);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = adminReducer(state, admin(), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 256 * irc.example.com :Administrative info')]),\n );\n });\n\n it('ignores the optional server target parameter', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = adminReducer(state, admin('foo'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),\n );\n });\n});\n\n// ---------------------------------------------------------------------------\n// INFO\n// ---------------------------------------------------------------------------\n\ndescribe('infoReducer', () => {\n it('emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = infoReducer(state, info(), ctx);\n\n const lines = (out.effects[0] as { lines: RawLine[] }).lines;\n expect(lines[0]).toEqual(\n L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),\n );\n expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));\n expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));\n });\n\n it('includes at least one 371 line and the 374 terminator', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = infoReducer(state, info(), ctx);\n\n const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);\n const rpl371Count = texts.filter((t) => / 371 /.test(t)).length;\n const rpl374Count = texts.filter((t) => / 374 /.test(t)).length;\n expect(rpl371Count).toBeGreaterThanOrEqual(1);\n expect(rpl374Count).toBe(1);\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state, 3_333);\n\n infoReducer(state, info(), ctx);\n\n expect(state.lastSeen).toBe(3_333);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n\n const out = infoReducer(state, info(), ctx);\n\n const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);\n expect(texts.every((t) => t.includes(' * '))).toBe(true);\n });\n});\n"},"tests/commands/setname.test.ts":{"tests":[{"id":"975","name":"setnameReducer — happy path updates state.realname from SETNAME :<"+"realname>"},{"id":"976","name":"setnameReducer — happy path emits no broadcast (silent update)"},{"id":"977","name":"setnameReducer — happy path overwrites a previously stored realname"},{"id":"978","name":"setnameReducer — happy path stamps lastSeen with ctx.clock.now()"},{"id":"979","name":"setnameReducer — WHOIS reflects the new realname a subsequent WHOIS returns the updated realname in 311 RPL_WHOISUSER"},{"id":"980","name":"setnameReducer — validation emits 461 ERR_NEEDMOREPARAMS when no realname param is given"},{"id":"981","name":"setnameReducer — validation emits 461 ERR_NEEDMOREPARAMS when the realname param is empty"},{"id":"982","name":"setnameReducer — validation emits 451 ERR_NOTREGISTERED when the connection is not registered"},{"id":"983","name":"setnameReducer — validation uses * in the numeric reply when the connection has no nick"},{"id":"984","name":"setnameReducer — length cap rejects an over-cap realname with 432 and leaves state.realname unchanged"},{"id":"985","name":"setnameReducer — length cap accepts a realname exactly at the cap"},{"id":"986","name":"setnameReducer — length cap applies the default cap when ServerConfig.realnameLen is unset"}],"source":"import { describe, expect, it } from 'vitest';\nimport { setnameReducer } from '../../src/commands/setname';\nimport { whoisReducer } from '../../src/commands/whois';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// setnameReducer — happy path\n// ============================================================================\n\ndescribe('setnameReducer — happy path', () => {\n it('updates state.realname from SETNAME :<"+"realname>', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);\n\n expect(conn.realname).toBe('New Name');\n expect(out.effects).toEqual<"+"EffectType[]>([]);\n });\n\n it('emits no broadcast (silent update)', () => {\n const conn = makeConn();\n conn.joinedChannels.add('#foo');\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);\n\n const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');\n expect(broadcasts).toHaveLength(0);\n });\n\n it('overwrites a previously stored realname', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n setnameReducer(conn, { command: 'SETNAME', params: ['Second'], tags: {} }, ctx);\n setnameReducer(conn, { command: 'SETNAME', params: ['Third'], tags: {} }, ctx);\n\n expect(conn.realname).toBe('Third');\n });\n\n it('stamps lastSeen with ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(9_900);\n const ctx = makeCtx(conn, clock);\n\n setnameReducer(conn, { command: 'SETNAME', params: ['X'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(9_900);\n });\n});\n\n// ============================================================================\n// setnameReducer — WHOIS reflects the new realname\n// ============================================================================\n\ndescribe('setnameReducer — WHOIS reflects the new realname', () => {\n it('a subsequent WHOIS returns the updated realname in 311 RPL_WHOISUSER', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n setnameReducer(conn, { command: 'SETNAME', params: ['Alice Wonderland'], tags: {} }, ctx);\n\n const out = whoisReducer(conn, [], { command: 'WHOIS', params: ['alice'] }, ctx);\n const firstBatch = out.effects[0];\n expect(firstBatch?.tag).toBe('Send');\n if (firstBatch?.tag === 'Send') {\n expect(firstBatch.lines[0]?.text).toBe(\n ':irc.example.com 311 alice alice alice example.com * :Alice Wonderland',\n );\n }\n });\n});\n\n// ============================================================================\n// setnameReducer — validation\n// ============================================================================\n\ndescribe('setnameReducer — validation', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no realname param is given', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice SETNAME :Not enough parameters')]),\n ]);\n expect(conn.realname).toBe('Alice');\n });\n\n it('emits 461 ERR_NEEDMOREPARAMS when the realname param is empty', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: [''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice SETNAME :Not enough parameters')]),\n ]);\n expect(conn.realname).toBe('Alice');\n });\n\n it('emits 451 ERR_NOTREGISTERED when the connection is not registered', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.nick = 'alice';\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 451 alice :You have not registered')]),\n ]);\n expect(conn.realname).toBeUndefined();\n });\n\n it('uses * in the numeric reply when the connection has no nick', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = setnameReducer(conn, { command: 'SETNAME', params: ['New Name'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 451 * :You have not registered')]),\n ]);\n });\n});\n\n// ============================================================================\n// setnameReducer — length cap\n// ============================================================================\n\ndescribe('setnameReducer — length cap', () => {\n it('rejects an over-cap realname with 432 and leaves state.realname unchanged', () => {\n const conn = makeConn();\n const cap = 5;\n const ctx: Ctx = buildCtx({\n serverConfig: { ...serverConfig, realnameLen: cap },\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = setnameReducer(\n conn,\n { command: 'SETNAME', params: ['Way Too Long'], tags: {} },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 432 alice :Erroneous realname')]),\n ]);\n expect(conn.realname).toBe('Alice');\n });\n\n it('accepts a realname exactly at the cap', () => {\n const conn = makeConn();\n const cap = 5;\n const ctx: Ctx = buildCtx({\n serverConfig: { ...serverConfig, realnameLen: cap },\n clock: new FakeClock(1_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n setnameReducer(conn, { command: 'SETNAME', params: ['seven'], tags: {} }, ctx);\n\n expect(conn.realname).toBe('seven');\n });\n\n it('applies the default cap when ServerConfig.realnameLen is unset', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const atDefault = 'x'.repeat(50);\n setnameReducer(conn, { command: 'SETNAME', params: [atDefault], tags: {} }, ctx);\n expect(conn.realname).toBe(atDefault);\n\n conn.realname = 'Alice';\n const overDefault = 'x'.repeat(51);\n const out = setnameReducer(conn, { command: 'SETNAME', params: [overDefault], tags: {} }, ctx);\n expect(out.effects[0]?.tag).toBe('Send');\n expect(conn.realname).toBe('Alice');\n });\n});\n"},"tests/commands/stats.test.ts":{"tests":[{"id":"987","name":"statsReducer — STATS u (uptime) emits 242 RPL_STATSUPTIME with a formatted uptime line"},{"id":"988","name":"statsReducer — STATS u (uptime) formats days/hours/minutes/seconds from the uptime delta"},{"id":"989","name":"statsReducer — STATS u (uptime) terminates the STATS u reply with 219 RPL_ENDOFSTATS carrying the query letter"},{"id":"990","name":"statsReducer — STATS u (uptime) updates the requester lastSeen to ctx.clock.now()"},{"id":"991","name":"statsReducer — STATS l (link info) emits 211 RPL_STATSLINKINFO for the local server (single-hop, serverless)"},{"id":"992","name":"statsReducer — STATS l (link info) terminates the STATS l reply with 219 RPL_ENDOFSTATS"},{"id":"993","name":"statsReducer — unknown query letter emits only the 219 terminator (charybdis-style) for an unknown letter"},{"id":"994","name":"statsReducer — unknown query letter passes the unknown query letter through to the 219 terminator"},{"id":"995","name":"statsReducer — no query letter emits only 219 with an empty query letter when STATS is invoked without args"},{"id":"996","name":"statsReducer — remote server target emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target"},{"id":"997","name":"statsReducer — remote server target uses \"*\" as the nick in 402 when the requester has none"},{"id":"998","name":"statsReducer — \"*\" nick fallback uses \"*\" as the nick when the requester has none"},{"id":"999","name":"statsReducer — \"*\" nick fallback uses \"*\" as the requester in STATS l when the requester has no nick"},{"id":"1000","name":"statsReducer — \"*\" nick fallback omits the trailing version when serverConfig.serverVersion is undefined"}],"source":"import { describe, expect, it } from 'vitest';\nimport { statsReducer } from '../../src/commands/stats';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n SequentialIdFactory,\n type ServerStatsSnapshot,\n} from '../../src/ports';\nimport { createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n serverVersion: 'serverless-ircd-0.4.0',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(connId = 'c1', nick = 'alice', clock = new FakeClock(5_000)): Ctx {\n const conn = createConnection({ id: connId, connectedSince: 0 });\n conn.nick = nick;\n conn.user = nick;\n conn.host = 'example.com';\n conn.realname = 'Alice';\n conn.registration = 'registered';\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst stats = (...params: string[]) =>\n ({\n command: 'STATS',\n params,\n tags: {},\n }) as const;\n\nconst snap = (uptimeStartedAt = 1_000): ServerStatsSnapshot => ({\n users: 0,\n invisible: 0,\n opers: 0,\n unknownConnections: 0,\n channels: 0,\n servers: 1,\n localConns: 0,\n globalConns: 0,\n maxLocalConns: 0,\n maxGlobalConns: 0,\n uptimeStartedAt,\n});\n\n// ============================================================================\n// statsReducer — STATS u (uptime)\n// ============================================================================\n\ndescribe('statsReducer — STATS u (uptime)', () => {\n it('emits 242 RPL_STATSUPTIME with a formatted uptime line', () => {\n const ctx = makeCtx();\n // uptimeStartedAt=0, clock=5_000 → 5 seconds uptime.\n const s = snap(0);\n\n const out = statsReducer(s, stats('u'), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 242 alice :Server Up 0 days, 0:00:05')]),\n );\n });\n\n it('formats days/hours/minutes/seconds from the uptime delta', () => {\n const ctx = makeCtx(\n 'c1',\n 'alice',\n new FakeClock(1 * 86_400_000 + 2 * 3_600_000 + 3 * 60_000 + 4_000),\n );\n const s = snap(0);\n\n const out = statsReducer(s, stats('u'), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 242 alice :Server Up 1 days, 2:03:04')]),\n );\n });\n\n it('terminates the STATS u reply with 219 RPL_ENDOFSTATS carrying the query letter', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats('u'), ctx);\n\n expect(out.effects).toContainEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 219 alice u :End of /STATS report')]),\n );\n });\n\n it('updates the requester lastSeen to ctx.clock.now()', () => {\n const ctx = makeCtx();\n expect(ctx.connection.lastSeen).toBe(0);\n\n statsReducer(snap(0), stats('u'), ctx);\n\n expect(ctx.connection.lastSeen).toBe(5_000);\n });\n});\n\n// ============================================================================\n// statsReducer — STATS l (link info)\n// ============================================================================\n\ndescribe('statsReducer — STATS l (link info)', () => {\n it('emits 211 RPL_STATSLINKINFO for the local server (single-hop, serverless)', () => {\n const ctx = makeCtx();\n const s = snap(0);\n\n const out = statsReducer(s, stats('l'), ctx);\n\n // Format: <"+"sendq> <"+"nick|server> <"+"send-bytes> <"+"send-msgs> <"+"rc-bytes>\n // <"+"rc-msgs> <"+"time-open> <"+"time-since-last> <"+"who> <"+"desc>\n // Single-server: one line for the bound server, hops=0.\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [\n L(\n `:irc.example.com 211 alice irc.example.com 0 0 0 0 0 0 :${ctx.serverConfig.serverVersion ?? ''}`,\n ),\n ]),\n );\n });\n\n it('terminates the STATS l reply with 219 RPL_ENDOFSTATS', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats('l'), ctx);\n\n const last = out.effects[out.effects.length - 1];\n expect(last).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 219 alice l :End of /STATS report')]),\n );\n });\n});\n\n// ============================================================================\n// statsReducer — unknown query letter\n// ============================================================================\n\ndescribe('statsReducer — unknown query letter', () => {\n it('emits only the 219 terminator (charybdis-style) for an unknown letter', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats('z'), ctx);\n\n // Charybdis: unknown STATS letters produce no body lines, just 219.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 219 alice z :End of /STATS report')]),\n ]);\n });\n\n it('passes the unknown query letter through to the 219 terminator', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats('Q'), ctx);\n\n // Letter is normalised to lowercase in the terminator.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 219 alice q :End of /STATS report')]),\n ]);\n });\n});\n\n// ============================================================================\n// statsReducer — no query letter\n// ============================================================================\n\ndescribe('statsReducer — no query letter', () => {\n it('emits only 219 with an empty query letter when STATS is invoked without args', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 219 alice :End of /STATS report')]),\n ]);\n });\n});\n\n// ============================================================================\n// statsReducer — remote server target\n// ============================================================================\n\ndescribe('statsReducer — remote server target', () => {\n it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {\n const ctx = makeCtx();\n\n const out = statsReducer(snap(0), stats('u', 'remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),\n ]);\n });\n\n it('uses \"*\" as the nick in 402 when the requester has none', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = statsReducer(snap(0), stats('u', 'remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),\n ]);\n });\n});\n\n// ============================================================================\n// statsReducer — wire-shape details\n// ============================================================================\n\ndescribe('statsReducer — \"*\" nick fallback', () => {\n it('uses \"*\" as the nick when the requester has none', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = statsReducer(snap(0), stats('u'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 242 * :Server Up 0 days, 0:00:05')]),\n );\n });\n\n it('uses \"*\" as the requester in STATS l when the requester has no nick', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const ctx = buildCtx({\n serverConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = statsReducer(snap(0), stats('l'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [\n L(':irc.example.com 211 * irc.example.com 0 0 0 0 0 0 :serverless-ircd-0.4.0'),\n ]),\n );\n });\n\n it('omits the trailing version when serverConfig.serverVersion is undefined', () => {\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.registration = 'registered';\n const { serverVersion: _omit, ...configNoVersion } = serverConfig;\n void _omit;\n const ctx = buildCtx({\n serverConfig: configNoVersion,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n\n const out = statsReducer(snap(0), stats('l'), ctx);\n\n // Trailing colon is empty when no serverVersion is configured.\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 211 * irc.example.com 0 0 0 0 0 0 :')]),\n );\n });\n});\n"},"tests/commands/tagmsg.test.ts":{"tests":[{"id":"1001","name":"tagmsgChannelReducer — success broadcasts TAGMSG to channel members (excluding sender) with the tag prefix"},{"id":"1002","name":"tagmsgChannelReducer — success broadcasts without an @ prefix when no tags are present"},{"id":"1003","name":"tagmsgChannelReducer — success serializes multiple tags correctly"},{"id":"1004","name":"tagmsgChannelReducer — success serializes a valueless tag as a bare key (no =suffix)"},{"id":"1005","name":"tagmsgChannelReducer — success updates the connection lastSeen to ctx.clock.now()"},{"id":"1006","name":"tagmsgChannelReducer — success returns the same state reference (no mutation to channel state)"},{"id":"1007","name":"tagmsgChannelReducer — cap gate emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated"},{"id":"1008","name":"tagmsgChannelReducer — cap gate uses * as the nick in 421 when the connection has no nick"},{"id":"1009","name":"tagmsgChannelReducer — silent rejections MUST NOT produce any numeric reply when target is missing"},{"id":"1010","name":"tagmsgChannelReducer — silent rejections MUST NOT produce any numeric reply when rejected by +n (no external)"},{"id":"1011","name":"tagmsgChannelReducer — silent rejections MUST NOT produce any numeric reply when rejected by +m (moderated)"},{"id":"1012","name":"tagmsgChannelReducer — silent rejections MUST NOT produce any numeric reply when +m rejects a non-member (no +n)"},{"id":"1013","name":"tagmsgChannelReducer — silent rejections still broadcasts when the sender is an op of a +m channel"},{"id":"1014","name":"tagmsgChannelReducer — silent rejections still broadcasts when the sender is voiced on a +m channel"},{"id":"1015","name":"tagmsgChannelReducer — silent rejections MUST NOT produce any numeric reply when banned (+b)"},{"id":"1016","name":"tagmsgChannelReducer — silent rejections honors the ? wildcard in ban masks"},{"id":"1017","name":"tagmsgChannelReducer — silent rejections proceeds with the broadcast when the ban list is non-empty but no mask matches"},{"id":"1018","name":"tagmsgChannelReducer — echo-message echoes the TAGMSG back to the sender when echo-message cap is present"},{"id":"1019","name":"tagmsgChannelReducer — echo-message does NOT echo when echo-message cap is absent"},{"id":"1020","name":"tagmsgChannelReducer — draft/typing broadcast emits a Broadcast with caps=[message-tags, draft/typing] for a +draft/typing TAGMSG"},{"id":"1021","name":"tagmsgChannelReducer — draft/typing broadcast still uses cap=message-tags (single cap) for a non-typing TAGMSG"},{"id":"1022","name":"tagmsgChannelReducer — draft/typing broadcast treats a valueless +draft/typing tag as a typing TAGMSG"},{"id":"1023","name":"tagmsgChannelReducer — draft/typing broadcast excludes the sender unless echo-message is negotiated (typing TAGMSG)"},{"id":"1024","name":"tagmsgChannelReducer — draft/typing broadcast echoes the typing TAGMSG back to the sender when echo-message is negotiated"},{"id":"1025","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence emits a Broadcast with caps=[message-tags, draft/read-marker] carrying the tag"},{"id":"1026","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence persists the marker to the ReadMarkerStore keyed by (account, channel)"},{"id":"1027","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence advances the connection in-memory lastReadMarkers"},{"id":"1028","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence overwrites a previously stored marker when a newer msgid arrives"},{"id":"1029","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence does NOT persist to the store when the connection has no account"},{"id":"1030","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence does NOT persist when no ReadMarkerStore is bound but still fans out"},{"id":"1031","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence excludes the sender unless echo-message is negotiated"},{"id":"1032","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence echoes the read-marker TAGMSG back to the sender when echo-message is negotiated"},{"id":"1033","name":"tagmsgChannelReducer — draft/read-marker broadcast + persistence fans out a valueless +draft/read-marker tag but persists no msgid"},{"id":"1034","name":"tagmsgChannelReducer — chathistory recording records the TAGMSG into ctx.messages with a fresh msgid and time"},{"id":"1035","name":"tagmsgChannelReducer — chathistory recording does NOT crash when no MessageStore is bound"},{"id":"1036","name":"tagmsgChannelReducer — chathistory recording does NOT record when rejected by +n"},{"id":"1037","name":"tagmsgChannelReducer — chathistory recording does NOT record when rejected by +b"},{"id":"1038","name":"tagmsgChannelReducer — defensive hostmask falls back to the bare nick when user/host are absent"},{"id":"1039","name":"tagmsgChannelReducer — defensive hostmask falls back to ? when the connection has no nick at all"},{"id":"1040","name":"tagmsgUserReducer routes a private TAGMSG via SendToNick"},{"id":"1041","name":"tagmsgUserReducer has no notFoundLines for an offline target (silent like NOTICE)"},{"id":"1042","name":"tagmsgUserReducer emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated"},{"id":"1043","name":"tagmsgUserReducer MUST NOT produce any numeric reply when target is missing"},{"id":"1044","name":"tagmsgUserReducer echoes back to the sender when echo-message cap is present"},{"id":"1045","name":"tagmsgUserReducer works without tags (no @ prefix)"},{"id":"1046","name":"tagmsgUserReducer routes a +draft/typing TAGMSG to a single nick target"},{"id":"1047","name":"tagmsgUserReducer excludes the sender from the echo for a +draft/typing TAGMSG without echo-message"},{"id":"1048","name":"tagmsgUserReducer updates lastSeen to ctx.clock.now()"},{"id":"1049","name":"tagmsgUserReducer returns the same state reference"},{"id":"1050","name":"tagmsgUserReducer does NOT record a user-target TAGMSG"}],"source":"import { describe, expect, it } from 'vitest';\nimport { tagmsgChannelReducer, tagmsgUserReducer } from '../../src/commands/tagmsg';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryMessageStore,\n InMemoryReadMarkerStore,\n type MessageStore,\n type ReadMarkerStore,\n SequentialIdFactory,\n type StoredMessage,\n} from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(\n conn: ConnectionState,\n clock = new FakeClock(1_000),\n messages?: MessageStore,\n readMarkers?: ReadMarkerStore,\n): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n ...(messages !== undefined ? { messages } : {}),\n ...(readMarkers !== undefined ? { readMarkers } : {}),\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n s.caps.add('message-tags');\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// tagmsgChannelReducer — success path\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — success', () => {\n it('broadcasts TAGMSG to channel members (excluding sender) with the tag prefix', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast(\n '#foo',\n [L('@+typing=active :alice!alice@example.com TAGMSG #foo')],\n 'c1',\n 'message-tags',\n ),\n ]);\n });\n\n it('broadcasts without an @ prefix when no tags are present', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [L(':alice!alice@example.com TAGMSG #foo')], 'c1', 'message-tags'),\n ]);\n });\n\n it('serializes multiple tags correctly', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active', 'vendor/foo': 'bar' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast(\n '#foo',\n [L('@+typing=active;vendor/foo=bar :alice!alice@example.com TAGMSG #foo')],\n 'c1',\n 'message-tags',\n ),\n ]);\n });\n\n it('serializes a valueless tag as a bare key (no =suffix)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+ack': '' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast(\n '#foo',\n [L('@+ack :alice!alice@example.com TAGMSG #foo')],\n 'c1',\n 'message-tags',\n ),\n ]);\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(8_800);\n const ctx = makeCtx(conn, clock);\n\n tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(8_800);\n });\n\n it('returns the same state reference (no mutation to channel state)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.state).toBe(chan);\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — cap gate\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — cap gate', () => {\n it('emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.delete('message-tags');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 421 alice TAGMSG :Unknown command')]),\n ]);\n });\n\n it('uses * as the nick in 421 when the connection has no nick', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 421 * TAGMSG :Unknown command')]),\n ]);\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — NOTICE-style silent rejections\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — silent rejections', () => {\n it('MUST NOT produce any numeric reply when target is missing', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: [], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when rejected by +n (no external)', () => {\n const chan = makeChan('#foo');\n chan.modes.noExternal = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when rejected by +m (moderated)', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('MUST NOT produce any numeric reply when +m rejects a non-member (no +n)', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c2', 'bob', true, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('still broadcasts when the sender is an op of a +m channel', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', true, false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('still broadcasts when the sender is voiced on a +m channel', () => {\n const chan = makeChan('#foo');\n chan.modes.moderated = true;\n addMember(chan, 'c1', 'alice', false, true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('MUST NOT produce any numeric reply when banned (+b)', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@example.com');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('honors the ? wildcard in ban masks', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('alice!?????@example.com');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('proceeds with the broadcast when the ban list is non-empty but no mask matches', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@baddomain.example');\n chan.banMasks.add('evil!*@*');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — echo-message\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — echo-message', () => {\n it('echoes the TAGMSG back to the sender when echo-message cap is present', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('echo-message');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n const line = L('@+typing=active :alice!alice@example.com TAGMSG #foo');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast('#foo', [line], 'c1', 'message-tags'),\n Effect.send('c1', [line]),\n ]);\n });\n\n it('does NOT echo when echo-message cap is absent', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — draft/typing broadcast\n// ============================================================================\n//\n// IRCv3 draft/typing: a TAGMSG carrying `+draft/typing=<"+"active|paused|done>`\n// fans out to channel members that negotiated `draft/typing` OR\n// `message-tags`. The `message-tags` path is the baseline (every TAGMSG goes\n// to message-tags peers); `draft/typing` adds the spec's client-tag\n// exception — peers that lack `message-tags` but negotiated `draft/typing`\n// still receive the typing TAGMSG. The reducer annotates the Broadcast\n// effect with `caps` (OR semantics) so dispatch routes per-recipient.\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — draft/typing broadcast', () => {\n it('emits a Broadcast with caps=[message-tags, draft/typing] for a +draft/typing TAGMSG', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n {\n tag: 'Broadcast',\n chan: '#foo',\n lines: [L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo')],\n except: 'c1',\n caps: ['message-tags', 'draft/typing'],\n },\n ]);\n });\n\n it('still uses cap=message-tags (single cap) for a non-typing TAGMSG', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcast(\n '#foo',\n [L('@+typing=active :alice!alice@example.com TAGMSG #foo')],\n 'c1',\n 'message-tags',\n ),\n ]);\n });\n\n it('treats a valueless +draft/typing tag as a typing TAGMSG', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': '' } },\n ctx,\n );\n\n const broadcast = out.effects[0];\n expect(broadcast?.tag).toBe('Broadcast');\n expect((broadcast as Extract<"+"EffectType, { tag: 'Broadcast' }>).caps).toEqual([\n 'message-tags',\n 'draft/typing',\n ]);\n });\n\n it('excludes the sender unless echo-message is negotiated (typing TAGMSG)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect((out.effects[0] as Extract<"+"EffectType, { tag: 'Broadcast' }>).except).toBe('c1');\n });\n\n it('echoes the typing TAGMSG back to the sender when echo-message is negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('echo-message');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },\n ctx,\n );\n\n const line = L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo');\n expect(out.effects).toEqual<"+"EffectType[]>([\n {\n tag: 'Broadcast',\n chan: '#foo',\n lines: [line],\n except: 'c1',\n caps: ['message-tags', 'draft/typing'],\n },\n Effect.send('c1', [line]),\n ]);\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — draft/read-marker broadcast + persistence\n// ============================================================================\n//\n// IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<"+"msgid>`\n// marks `<"+"msgid>` as the last read message for the sender's account in the\n// target channel. The reducer (1) persists the marker via the bound\n// ReadMarkerStore keyed by (account, channel), (2) advances the connection's\n// in-memory lastReadMarkers, and (3) fans the TAGMSG out — carrying the\n// `+draft/read-marker` tag — to members that negotiated `draft/read-marker`\n// OR `message-tags` (OR-semantics, matching the draft/typing precedent).\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', () => {\n it('emits a Broadcast with caps=[message-tags, draft/read-marker] carrying the tag', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm42' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n {\n tag: 'Broadcast',\n chan: '#foo',\n lines: [L('@+draft/read-marker=m42 :alice!alice@example.com TAGMSG #foo')],\n except: 'c1',\n caps: ['message-tags', 'draft/read-marker'],\n },\n ]);\n });\n\n it('persists the marker to the ReadMarkerStore keyed by (account, channel)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.account = 'alice';\n const store = new InMemoryReadMarkerStore();\n const ctx = makeCtx(conn, undefined, undefined, store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },\n ctx,\n );\n\n expect(store.get('alice', '#foo')).toBe('m7');\n });\n\n it('advances the connection in-memory lastReadMarkers', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.account = 'alice';\n const ctx = makeCtx(conn, undefined, undefined, new InMemoryReadMarkerStore());\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },\n ctx,\n );\n\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m7');\n });\n\n it('overwrites a previously stored marker when a newer msgid arrives', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.account = 'alice';\n const store = new InMemoryReadMarkerStore();\n const ctx = makeCtx(conn, undefined, undefined, store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },\n ctx,\n );\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm2' } },\n ctx,\n );\n\n expect(store.get('alice', '#foo')).toBe('m2');\n });\n\n it('does NOT persist to the store when the connection has no account', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn(); // no account\n const store = new InMemoryReadMarkerStore();\n const ctx = makeCtx(conn, undefined, undefined, store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },\n ctx,\n );\n\n expect(store.forAccount('alice')).toEqual([]);\n // The in-memory marker still advances for the session.\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');\n });\n\n it('does NOT persist when no ReadMarkerStore is bound but still fans out', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.account = 'alice';\n const ctx = makeCtx(conn); // no store\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect((out.effects[0] as Extract<"+"EffectType, { tag: 'Broadcast' }>).caps).toEqual([\n 'message-tags',\n 'draft/read-marker',\n ]);\n expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');\n });\n\n it('excludes the sender unless echo-message is negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect((out.effects[0] as Extract<"+"EffectType, { tag: 'Broadcast' }>).except).toBe('c1');\n });\n\n it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.caps.add('echo-message');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm3' } },\n ctx,\n );\n\n const line = L('@+draft/read-marker=m3 :alice!alice@example.com TAGMSG #foo');\n expect(out.effects).toEqual<"+"EffectType[]>([\n {\n tag: 'Broadcast',\n chan: '#foo',\n lines: [line],\n except: 'c1',\n caps: ['message-tags', 'draft/read-marker'],\n },\n Effect.send('c1', [line]),\n ]);\n });\n\n it('fans out a valueless +draft/read-marker tag but persists no msgid', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n conn.account = 'alice';\n const store = new InMemoryReadMarkerStore();\n const ctx = makeCtx(conn, undefined, undefined, store);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': '' } },\n ctx,\n );\n\n // Broadcast still widens to draft/read-marker peers (tag is present)...\n expect((out.effects[0] as Extract<"+"EffectType, { tag: 'Broadcast' }>).caps).toEqual([\n 'message-tags',\n 'draft/read-marker',\n ]);\n // ...but nothing is persisted (no msgid to store).\n expect(store.forAccount('alice')).toEqual([]);\n expect(conn.lastReadMarkers?.get('#foo')).toBeUndefined();\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — chathistory recording\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — chathistory recording', () => {\n it('records the TAGMSG into ctx.messages with a fresh msgid and time', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(conn, clock, store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });\n expect(recorded).toHaveLength(1);\n const entry = recorded[0] as StoredMessage | undefined;\n expect(entry).toBeDefined();\n expect(entry?.msgid).toBe('nonce-0');\n expect(entry?.time).toBe(7_700);\n expect(entry?.chan).toBe('#foo');\n expect(entry?.command).toBe('TAGMSG');\n expect(entry?.nick).toBe('alice');\n expect(entry?.user).toBe('alice');\n expect(entry?.host).toBe('example.com');\n expect(entry?.text).toBe('');\n });\n\n it('does NOT crash when no MessageStore is bound', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects[0]?.tag).toBe('Broadcast');\n });\n\n it('does NOT record when rejected by +n', () => {\n const chan = makeChan('#foo');\n chan.modes.noExternal = true;\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);\n });\n\n it('does NOT record when rejected by +b', () => {\n const chan = makeChan('#foo');\n chan.banMasks.add('*!*@example.com');\n addMember(chan, 'c1', 'alice');\n const store = new InMemoryMessageStore();\n const ctx = makeCtx(makeConn(), new FakeClock(1_000), store);\n\n tagmsgChannelReducer(\n chan,\n { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(store.query({ chan: '#foo', direction: 'latest', limit: 10 })).toEqual([]);\n });\n});\n\n// ============================================================================\n// tagmsgChannelReducer — defensive hostmask fallbacks\n// ============================================================================\n\ndescribe('tagmsgChannelReducer — defensive hostmask', () => {\n it('falls back to the bare nick when user/host are absent', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.nick = 'alice';\n conn.realname = 'Alice';\n conn.registration = 'registered';\n conn.caps.add('message-tags');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects[0];\n expect(broadcast?.tag).toBe('Broadcast');\n const lines = (broadcast as { lines: RawLine[] } | undefined)?.lines;\n expect(lines?.[0]?.text).toBe(':alice TAGMSG #foo');\n });\n\n it('falls back to ? when the connection has no nick at all', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n conn.caps.add('message-tags');\n const ctx = makeCtx(conn);\n\n const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);\n\n const broadcast = out.effects[0];\n expect(broadcast?.tag).toBe('Broadcast');\n const lines = (broadcast as { lines: RawLine[] } | undefined)?.lines;\n expect(lines?.[0]?.text).toBe(':? TAGMSG #foo');\n });\n});\n\n// ============================================================================\n// tagmsgUserReducer\n// ============================================================================\n\ndescribe('tagmsgUserReducer', () => {\n it('routes a private TAGMSG via SendToNick', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('bob', 'c1', [L('@+typing=active :alice!alice@example.com TAGMSG bob')]),\n ]);\n });\n\n it('has no notFoundLines for an offline target (silent like NOTICE)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['nobody'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('nobody', 'c1', [\n L('@+typing=active :alice!alice@example.com TAGMSG nobody'),\n ]),\n ]);\n });\n\n it('emits 421 ERR_UNKNOWNCOMMAND when message-tags cap is not negotiated', () => {\n const conn = makeConn();\n conn.caps.delete('message-tags');\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 421 alice TAGMSG :Unknown command')]),\n ]);\n });\n\n it('MUST NOT produce any numeric reply when target is missing', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: [], tags: { '+typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual([]);\n });\n\n it('echoes back to the sender when echo-message cap is present', () => {\n const conn = makeConn();\n conn.caps.add('echo-message');\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['bob'], tags: { '+typing': 'active' } },\n ctx,\n );\n\n const line = L('@+typing=active :alice!alice@example.com TAGMSG bob');\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('bob', 'c1', [line]),\n Effect.send('c1', [line]),\n ]);\n });\n\n it('works without tags (no @ prefix)', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com TAGMSG bob')]),\n ]);\n });\n\n it('routes a +draft/typing TAGMSG to a single nick target', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.sendToNick('bob', 'c1', [\n L('@+draft/typing=active :alice!alice@example.com TAGMSG bob'),\n ]),\n ]);\n });\n\n it('excludes the sender from the echo for a +draft/typing TAGMSG without echo-message', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(\n conn,\n { command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },\n ctx,\n );\n\n expect(out.effects).toHaveLength(1);\n expect(out.effects[0]?.tag).toBe('SendToNick');\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const conn = makeConn();\n const clock = new FakeClock(12_000);\n const ctx = makeCtx(conn, clock);\n\n tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(12_000);\n });\n\n it('returns the same state reference', () => {\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);\n\n expect(out.state).toBe(conn);\n });\n\n it('does NOT record a user-target TAGMSG', () => {\n const store = new InMemoryMessageStore();\n const conn = makeConn();\n const ctx = makeCtx(conn, new FakeClock(1_000), store);\n\n tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);\n\n expect(store.targets(0, 100_000)).toEqual([]);\n });\n});\n"},"tests/commands/topic.test.ts":{"tests":[{"id":"1051","name":"topicReducer — reads emits 331 RPL_NOTOPIC when the channel has no topic"},{"id":"1052","name":"topicReducer — reads emits 332 RPL_TOPIC and 333 RPL_TOPICWHOTIME when the channel has a topic"},{"id":"1053","name":"topicReducer — reads updates the connection lastSeen to ctx.clock.now() on a read"},{"id":"1054","name":"topicReducer — writes sets the topic and broadcasts TOPIC to the channel when a member writes"},{"id":"1055","name":"topicReducer — writes clears the topic when an empty topic is supplied"},{"id":"1056","name":"topicReducer — writes uses ctx.clock.now() as the setAt timestamp"},{"id":"1057","name":"topicReducer — writes truncates the topic to serverConfig.topicLen bytes"},{"id":"1058","name":"topicReducer — writes emits 482 ERR_CHANOPRIVSNEEDED when +t is set and the writer is a non-op member"},{"id":"1059","name":"topicReducer — writes allows a non-op member to write when +t is NOT set (default)"},{"id":"1060","name":"topicReducer — writes allows an op to write when +t is set"},{"id":"1061","name":"topicReducer — writes falls back to ? as the TOPIC source when the writer has no nick (defensive)"},{"id":"1062","name":"topicReducer — rejections emits 461 ERR_NEEDMOREPARAMS when no channel is supplied"},{"id":"1063","name":"topicReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix"},{"id":"1064","name":"topicReducer — rejections emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma"},{"id":"1065","name":"topicReducer — rejections emits 403 ERR_NOSUCHCHANNEL for an empty channel name"},{"id":"1066","name":"topicReducer — rejections emits 442 ERR_NOTONCHANNEL when reading a channel the connection has not joined"},{"id":"1067","name":"topicReducer — rejections emits 442 ERR_NOTONCHANNEL when writing a channel the connection has not joined"},{"id":"1068","name":"topicReducer — rejections uses * in numeric replies when the connection has no nick (defensive)"},{"id":"1069","name":"topicReducer — rejections uses * in 332/333 read replies when the reading member has no nick (defensive)"}],"source":"import { describe, expect, it } from 'vitest';\nimport { topicReducer } from '../../src/commands/topic';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(chan: ChannelState, connId: string, nick: string, op = false): void {\n chan.members.set(connId, { conn: connId, nick, op, voice: false });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// topicReducer — reads\n// ============================================================================\n\ndescribe('topicReducer — reads', () => {\n it('emits 331 RPL_NOTOPIC when the channel has no topic', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 331 alice #foo :No topic is set')]),\n ]);\n });\n\n it('emits 332 RPL_TOPIC and 333 RPL_TOPICWHOTIME when the channel has a topic', () => {\n const chan = makeChan('#foo');\n chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 5_000 };\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 332 alice #foo :hello world'),\n L(':irc.example.com 333 alice #foo alice!alice@example.com 5000'),\n ]),\n ]);\n });\n\n it('updates the connection lastSeen to ctx.clock.now() on a read', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(11_000);\n const ctx = makeCtx(conn, clock);\n\n topicReducer(chan, { command: 'TOPIC', params: ['#foo'], tags: {} }, ctx);\n\n expect(conn.lastSeen).toBe(11_000);\n });\n});\n\n// ============================================================================\n// topicReducer — writes\n// ============================================================================\n\ndescribe('topicReducer — writes', () => {\n it('sets the topic and broadcasts TOPIC to the channel when a member writes', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(\n chan,\n { command: 'TOPIC', params: ['#foo', 'new topic'], tags: {} },\n ctx,\n );\n\n expect(out.state.topic).toEqual({\n text: 'new topic',\n setter: 'alice!alice@example.com',\n setAt: 1_000,\n });\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n topic: { text: 'new topic', setter: 'alice!alice@example.com', setAt: 1_000 },\n }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com TOPIC #foo :new topic')]),\n ]);\n });\n\n it('clears the topic when an empty topic is supplied', () => {\n const chan = makeChan('#foo');\n chan.topic = { text: 'old', setter: 'alice!alice@example.com', setAt: 1_000 };\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', ''], tags: {} }, ctx);\n\n expect(out.state.topic).toBeUndefined();\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', { topic: null }),\n Effect.broadcast('#foo', [L(':alice!alice@example.com TOPIC #foo :')]),\n ]);\n });\n\n it('uses ctx.clock.now() as the setAt timestamp', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(99_999);\n const ctx = makeCtx(conn, clock);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'hi'], tags: {} }, ctx);\n\n expect(out.state.topic?.setAt).toBe(99_999);\n });\n\n it('truncates the topic to serverConfig.topicLen bytes', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const longTopic = 'a'.repeat(serverConfig.topicLen + 10);\n const out = topicReducer(\n chan,\n { command: 'TOPIC', params: ['#foo', longTopic], tags: {} },\n ctx,\n );\n\n expect(out.state.topic?.text).toBe('a'.repeat(serverConfig.topicLen));\n });\n\n it('emits 482 ERR_CHANOPRIVSNEEDED when +t is set and the writer is a non-op member', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c1', 'alice', false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'new'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 482 alice #foo :You're not channel operator\")]),\n ]);\n expect(out.state.topic).toBeUndefined();\n });\n\n it('allows a non-op member to write when +t is NOT set (default)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice', false);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'new'], tags: {} }, ctx);\n\n expect(out.state.topic?.text).toBe('new');\n });\n\n it('allows an op to write when +t is set', () => {\n const chan = makeChan('#foo');\n chan.modes.topicLock = true;\n addMember(chan, 'c1', 'alice', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'new'], tags: {} }, ctx);\n\n expect(out.state.topic?.text).toBe('new');\n });\n\n it('falls back to ? as the TOPIC source when the writer has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', '?');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'new'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.applyChannelDelta('#foo', {\n topic: { text: 'new', setter: '?', setAt: 1_000 },\n }),\n Effect.broadcast('#foo', [L(':? TOPIC #foo :new')]),\n ]);\n });\n});\n\n// ============================================================================\n// topicReducer — rejections\n// ============================================================================\n\ndescribe('topicReducer — rejections', () => {\n it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 alice TOPIC :Not enough parameters')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name without a valid prefix', () => {\n const chan = makeChan('foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a comma', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo,bar'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice #foo,bar :No such channel')]),\n ]);\n });\n\n it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: [''], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when reading a channel the connection has not joined', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n });\n\n it('emits 442 ERR_NOTONCHANNEL when writing a channel the connection has not joined', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', 'new'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(\":irc.example.com 442 alice #foo :You're not on that channel\")]),\n ]);\n });\n\n it('uses * in numeric replies when the connection has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: [], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 461 * TOPIC :Not enough parameters')]),\n ]);\n });\n\n it('uses * in 332/333 read replies when the reading member has no nick (defensive)', () => {\n const chan = makeChan('#foo');\n chan.topic = { text: 'hello', setter: 'alice!alice@example.com', setAt: 5_000 };\n addMember(chan, 'c1', '?');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n\n const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo'], tags: {} }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 332 * #foo :hello'),\n L(':irc.example.com 333 * #foo alice!alice@example.com 5000'),\n ]),\n ]);\n });\n});\n"},"tests/commands/trace.test.ts":{"tests":[{"id":"1070","name":"traceReducer — no target emits the 262 terminator with no middle when there is no target"},{"id":"1071","name":"traceReducer — no target omits any 200/204/205 detail line when there is no target"},{"id":"1072","name":"traceReducer — no target updates the requester lastSeen to ctx.clock.now()"},{"id":"1073","name":"traceReducer — no target uses \"*\" as the nick when the requester has none"},{"id":"1074","name":"traceReducer — non-oper suppression emits only the 262 terminator for a non-oper TRACE <"+"nick> (no leak)"},{"id":"1075","name":"traceReducer — non-oper suppression does not emit a 205 detail line for a non-oper requester"},{"id":"1076","name":"traceReducer — oper detail emits a 205 RPL_TRACEUSER line for an oper tracing a normal user"},{"id":"1077","name":"traceReducer — oper detail emits a 204 RPL_TRACEOPERATOR line for an oper tracing another oper"},{"id":"1078","name":"traceReducer — oper detail uses \"*\" nick fallbacks in 204/205 lines for an oper without a nick"},{"id":"1079","name":"traceReducer — oper detail uses \"?\" / \"*\" fallbacks in 205 line when target lacks user/host/nick"},{"id":"1080","name":"traceReducer — +i invisible filtering omits detail for an invisible target a non-oper does not share a channel with"},{"id":"1081","name":"traceReducer — +i invisible filtering shows a 205 line for an invisible target when the requester is an oper (oper bypasses +i)"},{"id":"1082","name":"traceReducer — remote server target emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target"},{"id":"1083","name":"traceReducer — remote server target uses \"*\" nick in 402 when the requester has none"},{"id":"1084","name":"traceReducer — unknown nick emits 262 with the requested nick when target resolves to undefined"}],"source":"import { describe, expect, it } from 'vitest';\nimport { traceReducer } from '../../src/commands/trace';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice', oper = false): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n s.userModes.oper = oper;\n return s;\n}\n\nfunction makeTarget(\n id: string,\n nick: string,\n opts: { oper?: boolean; invisible?: boolean } = {},\n): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Real Name';\n s.registration = 'registered';\n if (opts.oper !== undefined) s.userModes.oper = opts.oper;\n if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst trace = (target?: string) =>\n ({\n command: 'TRACE',\n params: target === undefined ? [] : [target],\n tags: {},\n }) as const;\n\n// ============================================================================\n// traceReducer — no target\n// ============================================================================\n\ndescribe('traceReducer — no target', () => {\n it('emits the 262 terminator with no middle when there is no target', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 262 alice :End of TRACE')]),\n ]);\n });\n\n it('omits any 200/204/205 detail line when there is no target', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace(), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 200 ');\n expect(text).not.toContain(' 204 ');\n expect(text).not.toContain(' 205 ');\n });\n\n it('updates the requester lastSeen to ctx.clock.now()', () => {\n const requester = makeConn();\n expect(requester.lastSeen).toBe(0);\n const ctx = makeCtx(requester);\n\n traceReducer(undefined, trace(), ctx);\n\n expect(requester.lastSeen).toBe(5_000);\n });\n\n it('uses \"*\" as the nick when the requester has none', () => {\n const requester = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 262 * :End of TRACE')]),\n ]);\n });\n});\n\n// ============================================================================\n// traceReducer — non-oper detail suppression\n// ============================================================================\n\ndescribe('traceReducer — non-oper suppression', () => {\n it('emits only the 262 terminator for a non-oper TRACE <"+"nick> (no leak)', () => {\n const requester = makeConn('c1', 'alice', false);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),\n ]);\n });\n\n it('does not emit a 205 detail line for a non-oper requester', () => {\n const requester = makeConn('c1', 'alice', false);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n const text = out.effects\n .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))\n .join('|');\n expect(text).not.toContain(' 205 ');\n expect(text).not.toContain(' 204 ');\n });\n});\n\n// ============================================================================\n// traceReducer — oper detail\n// ============================================================================\n\ndescribe('traceReducer — oper detail', () => {\n it('emits a 205 RPL_TRACEUSER line for an oper tracing a normal user', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),\n Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),\n ]);\n });\n\n it('emits a 204 RPL_TRACEOPERATOR line for an oper tracing another oper', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob', { oper: true });\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 204 alice Oper 0 bob bob example.com')]),\n Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),\n ]);\n });\n\n it('uses \"*\" nick fallbacks in 204/205 lines for an oper without a nick', () => {\n const requester = createConnection({ id: 'c1', connectedSince: 0 });\n requester.userModes.oper = true;\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 205 * User 0 bob bob example.com')]),\n );\n });\n\n it('uses \"?\" / \"*\" fallbacks in 205 line when target lacks user/host/nick', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n const target = createConnection({ id: 'c2', connectedSince: 0 });\n target.registration = 'registered';\n\n const out = traceReducer(target, trace('ghost'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 205 alice User 0 * ? ?')]),\n );\n });\n});\n\n// ============================================================================\n// traceReducer — +i invisible filtering\n// ============================================================================\n\ndescribe('traceReducer — +i invisible filtering', () => {\n it('omits detail for an invisible target a non-oper does not share a channel with', () => {\n const requester = makeConn('c1', 'alice', false);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob', { invisible: true });\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),\n ]);\n });\n\n it('shows a 205 line for an invisible target when the requester is an oper (oper bypasses +i)', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob', { invisible: true });\n\n const out = traceReducer(target, trace('bob'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),\n );\n });\n});\n\n// ============================================================================\n// traceReducer — remote server target\n// ============================================================================\n\ndescribe('traceReducer — remote server target', () => {\n it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),\n ]);\n });\n\n it('uses \"*\" nick in 402 when the requester has none', () => {\n const requester = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),\n ]);\n });\n});\n\n// ============================================================================\n// traceReducer — unknown nick (actor could not resolve)\n// ============================================================================\n\ndescribe('traceReducer — unknown nick', () => {\n it('emits 262 with the requested nick when target resolves to undefined', () => {\n const requester = makeConn('c1', 'alice', true);\n const ctx = makeCtx(requester);\n\n const out = traceReducer(undefined, trace('ghost'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 262 alice ghost :End of TRACE')]),\n ]);\n });\n});\n"},"tests/commands/userhost.test.ts":{"tests":[{"id":"1085","name":"userhostReducer emits 302 RPL_USERHOST with the resolved hostmask for one nick"},{"id":"1086","name":"userhostReducer includes multiple replies space-separated in one 302 line"},{"id":"1087","name":"userhostReducer marks away users with - instead of +"},{"id":"1088","name":"userhostReducer prefixes oper users with *"},{"id":"1089","name":"userhostReducer omits nicks that are offline (null in the resolved map)"},{"id":"1090","name":"userhostReducer emits an empty trailing when no requested nicks are online"},{"id":"1091","name":"userhostReducer emits an empty trailing when no nicks are requested"},{"id":"1092","name":"userhostReducer uses * as nick placeholder for unregistered connections"},{"id":"1093","name":"userhostReducer updates lastSeen to ctx.clock.now()"},{"id":"1094","name":"userhostReducer returns the same state reference"},{"id":"1095","name":"userhostReducer respects the max-targets cap (caps the number of queried nicks)"},{"id":"1096","name":"userhostReducer skips an empty-string nick in the parameter list"},{"id":"1097","name":"userhostReducer omits a resolved snapshot that is missing required fields (host absent)"},{"id":"1098","name":"formatUserhostReply returns null when the snapshot is missing a nick"},{"id":"1099","name":"formatUserhostReply returns null when the snapshot is missing a user"},{"id":"1100","name":"formatUserhostReply returns null when the snapshot is missing a host"}],"source":"import { describe, expect, it } from 'vitest';\nimport { formatUserhostReply, userhostReducer } from '../../src/commands/userhost';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport {\n type ConnSnapshot,\n type ConnectionState,\n createConnection,\n toSnapshot,\n} from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst baseServerConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(state: ConnectionState): Ctx {\n return buildCtx({\n serverConfig: baseServerConfig,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n}\n\nfunction makeState(): ConnectionState {\n const s = createConnection({ id: 'c1', connectedSince: 0 });\n s.nick = 'alice';\n s.user = 'alice';\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nfunction makeSnapshot(\n nick: string,\n user: string,\n host: string,\n opts?: { away?: string; oper?: boolean },\n): ConnSnapshot {\n const s = createConnection({ id: `c-${nick}`, connectedSince: 0 });\n s.nick = nick;\n s.user = user;\n s.host = host;\n s.registration = 'registered';\n if (opts?.away !== undefined) s.away = opts.away;\n if (opts?.oper) s.userModes.oper = true;\n return toSnapshot(s);\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst userhost = (...nicks: string[]) =>\n ({ command: 'USERHOST', params: nicks, tags: {} }) as const;\n\ndescribe('userhostReducer', () => {\n it('emits 302 RPL_USERHOST with the resolved hostmask for one nick', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);\n\n const out = userhostReducer(resolved, userhost('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),\n ]);\n });\n\n it('includes multiple replies space-separated in one 302 line', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map([\n ['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],\n ['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],\n ]);\n\n const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 302 alice :bob=+bobuser@bobhost carol=+caroluser@carolhost'),\n ]),\n ]);\n });\n\n it('marks away users with - instead of +', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map([\n ['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { away: 'gone' })],\n ]);\n\n const out = userhostReducer(resolved, userhost('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :bob=-bobuser@bobhost')]),\n ]);\n });\n\n it('prefixes oper users with *', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { oper: true })]]);\n\n const out = userhostReducer(resolved, userhost('bob'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :bob*=+bobuser@bobhost')]),\n ]);\n });\n\n it('omits nicks that are offline (null in the resolved map)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map<"+"string, ConnSnapshot | null>([\n ['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],\n ['ghost', null],\n ]);\n\n const out = userhostReducer(resolved, userhost('bob', 'ghost'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),\n ]);\n });\n\n it('emits an empty trailing when no requested nicks are online', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map<"+"string, ConnSnapshot | null>([['ghost', null]]);\n\n const out = userhostReducer(resolved, userhost('ghost'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :')]),\n ]);\n });\n\n it('emits an empty trailing when no nicks are requested', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map<"+"string, ConnSnapshot | null>();\n\n const out = userhostReducer(resolved, userhost(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 302 alice :')]),\n ]);\n });\n\n it('uses * as nick placeholder for unregistered connections', () => {\n const state = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(state);\n const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);\n\n const out = userhostReducer(resolved, userhost('bob'), ctx);\n\n expect(out.effects[0]).toEqual<"+"EffectType>(\n Effect.send('c1', [L(':irc.example.com 302 * :bob=+bobuser@bobhost')]),\n );\n });\n\n it('updates lastSeen to ctx.clock.now()', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n userhostReducer(new Map(), userhost('bob'), ctx);\n\n expect(state.lastSeen).toBe(5_000);\n });\n\n it('returns the same state reference', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n\n const out = userhostReducer(new Map(), userhost(), ctx);\n\n expect(out.state).toBe(state);\n });\n\n it('respects the max-targets cap (caps the number of queried nicks)', () => {\n const state = makeState();\n const config: ServerConfig = { ...baseServerConfig, maxTargetsPerCommand: 2 };\n const ctx = buildCtx({\n serverConfig: config,\n clock: new FakeClock(5_000),\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: state,\n });\n const resolved = new Map([\n ['bob', makeSnapshot('bob', 'bu', 'bh')],\n ['carol', makeSnapshot('carol', 'cu', 'ch')],\n ['dave', makeSnapshot('dave', 'du', 'dh')],\n ]);\n\n const out = userhostReducer(resolved, userhost('bob', 'carol', 'dave'), ctx);\n\n const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;\n // Only the first two nicks should appear; the third is beyond the cap.\n expect(text).not.toContain('dave');\n expect(text).toContain('bob');\n expect(text).toContain('carol');\n });\n\n it('skips an empty-string nick in the parameter list', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);\n\n const out = userhostReducer(resolved, userhost('', 'bob'), ctx);\n\n const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;\n expect(text).toContain('bob=+bobuser@bobhost');\n });\n\n it('omits a resolved snapshot that is missing required fields (host absent)', () => {\n const state = makeState();\n const ctx = makeCtx(state);\n const incomplete = makeSnapshot('bob', 'bobuser', 'bobhost');\n (incomplete as { host: string | undefined }).host = undefined;\n const resolved = new Map<"+"string, ConnSnapshot | null>([\n ['bob', incomplete],\n ['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],\n ]);\n\n const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);\n\n const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;\n expect(text).not.toContain('bob');\n expect(text).toContain('carol=+caroluser@carolhost');\n });\n});\n\ndescribe('formatUserhostReply', () => {\n it('returns null when the snapshot is missing a nick', () => {\n const snap = makeSnapshot('bob', 'bu', 'bh');\n (snap as { nick: string | undefined }).nick = undefined;\n expect(formatUserhostReply(snap)).toBeNull();\n });\n\n it('returns null when the snapshot is missing a user', () => {\n const snap = makeSnapshot('bob', 'bu', 'bh');\n (snap as { user: string | undefined }).user = undefined;\n expect(formatUserhostReply(snap)).toBeNull();\n });\n\n it('returns null when the snapshot is missing a host', () => {\n const snap = makeSnapshot('bob', 'bu', 'bh');\n (snap as { host: string | undefined }).host = undefined;\n expect(formatUserhostReply(snap)).toBeNull();\n });\n});\n"},"tests/commands/wallops.test.ts":{"tests":[{"id":"1101","name":"wallopsReducer — oper happy path emits a global BroadcastWallops effect carrying the oper source prefix"},{"id":"1102","name":"wallopsReducer — oper happy path passes the message text through verbatim (multi-word)"},{"id":"1103","name":"wallopsReducer — oper happy path defaults except to the sender (skip-self, NOTICE/PRIVMSG semantics)"},{"id":"1104","name":"wallopsReducer — oper happy path updates the oper lastSeen to ctx.clock.now()"},{"id":"1105","name":"wallopsReducer — oper happy path returns the same state reference unchanged"},{"id":"1106","name":"wallopsReducer — oper happy path falls back to the bare nick when the hostmask is incomplete (no user/host)"},{"id":"1107","name":"wallopsReducer — oper happy path falls back to \"*\" when the oper has neither nick nor hostmask (defensive)"},{"id":"1108","name":"wallopsReducer — non-oper (481 ERR_NOPRIVILEGES) emits 481 when the sender is not an oper"},{"id":"1109","name":"wallopsReducer — non-oper (481 ERR_NOPRIVILEGES) does not emit a BroadcastWallops when the sender is not an oper"},{"id":"1110","name":"wallopsReducer — non-oper (481 ERR_NOPRIVILEGES) gates before param validation: a non-oper with no message still gets 481"},{"id":"1111","name":"wallopsReducer — non-oper (481 ERR_NOPRIVILEGES) uses \"*\" as the nick in 481 when the connection has no nick"},{"id":"1112","name":"wallopsReducer — empty message (412 ERR_NOTEXTTOSEND) emits 412 when no message parameter is supplied"},{"id":"1113","name":"wallopsReducer — empty message (412 ERR_NOTEXTTOSEND) emits 412 when the message is the empty string"},{"id":"1114","name":"wallopsReducer — empty message (412 ERR_NOTEXTTOSEND) does not broadcast when the message is empty"},{"id":"1115","name":"wallopsReducer — empty message (412 ERR_NOTEXTTOSEND) uses \"*\" as the nick in 412 when the connection has no nick"}],"source":"import { describe, expect, it } from 'vitest';\nimport { wallopsReducer } from '../../src/commands/wallops';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeOper(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n s.userModes.oper = true;\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\nconst wallops = (message?: string) =>\n ({\n command: 'WALLOPS',\n params: message === undefined ? [] : [message],\n tags: {},\n }) as const;\n\ndescribe('wallopsReducer — oper happy path', () => {\n it('emits a global BroadcastWallops effect carrying the oper source prefix', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hello'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :hello')], 'c1'),\n ]);\n });\n\n it('passes the message text through verbatim (multi-word)', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('restart in 5 minutes'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :restart in 5 minutes')], 'c1'),\n ]);\n });\n\n it('defaults except to the sender (skip-self, NOTICE/PRIVMSG semantics)', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects[0]?.tag).toBe('BroadcastWallops');\n // except is the oper's own connection id, so the runtime never echoes\n // the wallops back to its originator.\n expect((out.effects[0] as { except?: string }).except).toBe('c1');\n });\n\n it('updates the oper lastSeen to ctx.clock.now()', () => {\n const oper = makeOper();\n expect(oper.lastSeen).toBe(0);\n const ctx = makeCtx(oper);\n\n wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(oper.lastSeen).toBe(5_000);\n });\n\n it('returns the same state reference unchanged', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.state).toBe(oper);\n });\n\n it('falls back to the bare nick when the hostmask is incomplete (no user/host)', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.nick = 'alice';\n oper.registration = 'registered';\n oper.userModes.oper = true;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcastWallops([L(':alice WALLOPS :hi')], 'c1'),\n ]);\n });\n\n it('falls back to \"*\" when the oper has neither nick nor hostmask (defensive)', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.registration = 'registered';\n oper.userModes.oper = true;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.broadcastWallops([L(':* WALLOPS :hi')], 'c1'),\n ]);\n });\n});\n\ndescribe('wallopsReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {\n it('emits 481 when the sender is not an oper', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('does not emit a BroadcastWallops when the sender is not an oper', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);\n });\n\n it('gates before param validation: a non-oper with no message still gets 481', () => {\n const oper = makeOper();\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 alice :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n\n it('uses \"*\" as the nick in 481 when the connection has no nick', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.userModes.oper = false;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops('hi'), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(\":irc.example.com 481 * :Permission Denied - You're not an IRC operator\"),\n ]),\n ]);\n });\n});\n\ndescribe('wallopsReducer — empty message (412 ERR_NOTEXTTOSEND)', () => {\n it('emits 412 when no message parameter is supplied', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops(), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),\n ]);\n });\n\n it('emits 412 when the message is the empty string', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops(''), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),\n ]);\n });\n\n it('does not broadcast when the message is empty', () => {\n const oper = makeOper();\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops(''), ctx);\n\n expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);\n });\n\n it('uses \"*\" as the nick in 412 when the connection has no nick', () => {\n const oper = createConnection({ id: 'c1', connectedSince: 0 });\n oper.registration = 'registered';\n oper.userModes.oper = true;\n const ctx = makeCtx(oper);\n\n const out = wallopsReducer(oper, wallops(''), ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),\n ]);\n });\n});\n"},"tests/commands/who.test.ts":{"tests":[{"id":"1116","name":"whoReducer — channel target emits one 352 per channel member then 315 RPL_ENDOFWHO"},{"id":"1117","name":"whoReducer — channel target marks the away flag as G (gone) for away members"},{"id":"1118","name":"whoReducer — channel target marks the oper flag with * for IRC operators"},{"id":"1119","name":"whoReducer — channel target includes the @ prefix for ops and + prefix for voice"},{"id":"1120","name":"whoReducer — channel target shows only the highest-priority prefix in 352 when multi-prefix is NOT negotiated"},{"id":"1121","name":"whoReducer — channel target shows every applicable prefix in 352 when multi-prefix IS negotiated"},{"id":"1122","name":"whoReducer — channel target uses * for the server name (single-server v1)"},{"id":"1123","name":"whoReducer — channel target emits only 315 when the channel has no members"},{"id":"1124","name":"whoReducer — channel target hides +s channel members from a non-member requester"},{"id":"1125","name":"whoReducer — channel target shows +s channel members to a member requester"},{"id":"1126","name":"whoReducer — channel target skips +i (invisible) user-mode members unless the requester shares a channel"},{"id":"1127","name":"whoReducer — channel target hides members whose connection state is missing from the registry"},{"id":"1128","name":"whoReducer — operators-only filter with `o` flag lists only IRC operators (channel members)"},{"id":"1129","name":"whoReducer — errors emits 403 ERR_NOSUCHCHANNEL for an invalid channel name"},{"id":"1130","name":"whoReducer — errors rejects an empty channel name with 403"},{"id":"1131","name":"whoReducer — errors rejects a too-long channel name with 403"},{"id":"1132","name":"whoReducer — errors emits only 315 with an empty mask when no params are supplied"},{"id":"1133","name":"whoReducer — errors uses * in replies when the connection has no nick"},{"id":"1134","name":"whoReducer — errors updates the connection lastSeen to ctx.clock.now()"}],"source":"import { describe, expect, it } from 'vitest';\nimport { operReducer } from '../../src/commands/oper';\nimport { whoReducer } from '../../src/commands/who';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n operCreds: [\n { user: 'alice', password: 'oppass' },\n { user: 'bob', password: 'oppass' },\n ],\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\n/** Grants oper to a connection via the real OPER command flow. */\nfunction grantOper(conn: ConnectionState): void {\n const ctx = makeCtx(conn);\n const user = conn.nick ?? 'oper';\n operReducer(conn, { command: 'OPER', params: [user, 'oppass'], tags: {} }, ctx);\n}\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice', caps: ReadonlyArray<"+"string> = []): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);\n s.registration = 'registered';\n for (const c of caps) s.caps.add(c);\n return s;\n}\n\nfunction makeChan(name = '#foo'): ChannelState {\n return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nfunction makeConnState(\n id: string,\n nick: string,\n opts: {\n op?: boolean;\n away?: string;\n invisible?: boolean;\n oper?: boolean;\n realname?: string;\n } = {},\n): ConnectionState {\n const s = createConnection({ id, connectedSince: 1_000 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = opts.realname ?? nick;\n s.registration = 'registered';\n if (opts.away !== undefined) s.away = opts.away;\n if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;\n if (opts.oper !== undefined) s.userModes.oper = opts.oper;\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// whoReducer — channel target\n// ============================================================================\n\ndescribe('whoReducer — channel target', () => {\n it('emits one 352 per channel member then 315 RPL_ENDOFWHO', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob', { op: true })],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 352 alice #foo alice example.com irc.example.com alice H :0 alice'),\n L(':irc.example.com 352 alice #foo bob example.com irc.example.com bob H@ :0 bob'),\n ]),\n Effect.send('c1', [L(':irc.example.com 315 alice #foo :End of /WHO list')]),\n ]);\n });\n\n it('marks the away flag as G (gone) for away members', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice', { away: 'brb' })],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain(' G ');\n }\n });\n\n it('marks the oper flag with * for IRC operators', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const alice = makeConnState('c1', 'alice');\n // Grant oper via the real OPER command flow, not by poking state.\n grantOper(alice);\n expect(alice.userModes.oper).toBe(true);\n const connStates = new Map<"+"string, ConnectionState>([['c1', alice]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n // Oper flag appears between hops and the away flag: `<"+"hops> <"+"awayFlag>[*]<"+"prefixes>`\n // The 352 layout: `<"+"chan> <"+"user> <"+"host> <"+"server> <"+"nick> <"+"H|G>[*][~|&|@|%|+] :<"+"hops> <"+"real>`\n expect(send.lines[0]?.text).toContain(' H* ');\n }\n });\n\n it('includes the @ prefix for ops and + prefix for voice', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true, true);\n addMember(chan, 'c3', 'carol', false, true);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob')],\n ['c3', makeConnState('c3', 'carol')],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n const bob = send.lines[1]?.text ?? '';\n const carol = send.lines[2]?.text ?? '';\n expect(bob).toContain('H@');\n expect(carol).toContain('H+');\n }\n });\n\n it('shows only the highest-priority prefix in 352 when multi-prefix is NOT negotiated', () => {\n // Legacy client: op+voice user appears as `H@` only, NOT `H@+`.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true, true); // op AND voice\n const conn = makeConn('c1', 'alice'); // no multi-prefix cap\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob')],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n const bob = send.lines[1]?.text ?? '';\n // Flag field is the token before the trailing ` :0 real` segment.\n // We expect exactly `H@` (with the space after), not `H@+`.\n expect(bob).toContain(' H@ ');\n expect(bob).not.toContain('H@+');\n }\n });\n\n it('shows every applicable prefix in 352 when multi-prefix IS negotiated', () => {\n // Cap-enabled client: op+voice user appears as `H@+`.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob', true, true); // op AND voice\n const conn = makeConn('c1', 'alice', ['multi-prefix']);\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob')],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n const bob = send.lines[1]?.text ?? '';\n expect(bob).toContain('H@+');\n }\n });\n\n it('uses * for the server name (single-server v1)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n // The 352 server field is the configured server name; single-server v1\n // reports the local server for every member.\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain(' irc.example.com alice ');\n }\n });\n\n it('emits only 315 when the channel has no members', () => {\n const chan = makeChan('#foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>();\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 315 alice #foo :End of /WHO list')]),\n ]);\n });\n\n it('hides +s channel members from a non-member requester', () => {\n const chan = makeChan('#secret');\n chan.modes.secret = true;\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([['c2', makeConnState('c2', 'bob')]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#secret'] }, ctx);\n\n // Non-member of a +s channel gets empty list + end.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 315 alice #secret :End of /WHO list')]),\n ]);\n });\n\n it('shows +s channel members to a member requester', () => {\n const chan = makeChan('#secret');\n chan.modes.secret = true;\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob')],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#secret'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines.length).toBe(2);\n }\n });\n\n it('skips +i (invisible) user-mode members unless the requester shares a channel', () => {\n // In v1, the requester IS on the channel (they sent WHO #foo), so invisible\n // members of #foo are still listed: they're visible by virtue of shared\n // channel membership. This test confirms that behavior.\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', makeConnState('c2', 'bob', { invisible: true })],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n // Both members are listed: requester shares the channel with the\n // invisible user, so they're visible.\n expect(send.lines.length).toBe(2);\n }\n });\n\n it('hides members whose connection state is missing from the registry', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'ghost'); // no ConnectionState entry for c2\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines.length).toBe(1);\n }\n });\n});\n\n// ============================================================================\n// whoReducer — `o` filter (operators only)\n// ============================================================================\n\ndescribe('whoReducer — operators-only filter', () => {\n it('with `o` flag lists only IRC operators (channel members)', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n addMember(chan, 'c2', 'bob');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const bob = makeConnState('c2', 'bob');\n // Grant oper via the real OPER command flow, not by poking state.\n grantOper(bob);\n expect(bob.userModes.oper).toBe(true);\n const connStates = new Map<"+"string, ConnectionState>([\n ['c1', makeConnState('c1', 'alice')],\n ['c2', bob],\n ]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo', 'o'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines.length).toBe(1);\n expect(send.lines[0]?.text).toContain(' bob ');\n }\n });\n});\n\n// ============================================================================\n// whoReducer — errors\n// ============================================================================\n\ndescribe('whoReducer — errors', () => {\n it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {\n const chan = makeChan('foo');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>();\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['foo'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),\n ]);\n });\n\n it('rejects an empty channel name with 403', () => {\n const chan = makeChan('');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>();\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: [''] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain(' 403 ');\n }\n });\n\n it('rejects a too-long channel name with 403', () => {\n const long = `#${'x'.repeat(serverConfig.channelLen)}`;\n const chan = makeChan(long);\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>();\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: [long] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain(' 403 ');\n }\n });\n\n it('emits only 315 with an empty mask when no params are supplied', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: [] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 315 alice :End of /WHO list')]),\n ]);\n });\n\n it('uses * in replies when the connection has no nick', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(conn);\n const connStates = new Map<"+"string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);\n\n const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n const end = out.effects.at(-1);\n if (end?.tag === 'Send') {\n expect(end.lines[0]?.text).toBe(':irc.example.com 315 * #foo :End of /WHO list');\n }\n });\n\n it('updates the connection lastSeen to ctx.clock.now()', () => {\n const chan = makeChan('#foo');\n addMember(chan, 'c1', 'alice');\n const conn = makeConn();\n const clock = new FakeClock(5_500);\n const ctx = makeCtx(conn, clock);\n const connStates = new Map<"+"string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);\n\n whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);\n\n expect(conn.lastSeen).toBe(5_500);\n });\n});\n"},"tests/commands/whois.test.ts":{"tests":[{"id":"1135","name":"whoisReducer — happy path emits 311/312/317/318 for a registered user with no channels"},{"id":"1136","name":"whoisReducer — happy path emits 319 RPL_WHOISCHANNELS between 317 and 318 when the target is in channels"},{"id":"1137","name":"whoisReducer — happy path emits 330 RPL_WHOISACCOUNT when the target has an account"},{"id":"1138","name":"whoisReducer — happy path emits 313 RPL_WHOISOPERATOR when the target is an IRC operator"},{"id":"1139","name":"whoisReducer — happy path emits 276 RPL_WHOISSECURE when the target connected over TLS (user mode S)"},{"id":"1140","name":"whoisReducer — happy path does not emit 276 when the target is not on a secure connection"},{"id":"1141","name":"whoisReducer — idle and signon reports idle seconds as floor((now - lastSeen) / 1000)"},{"id":"1142","name":"whoisReducer — idle and signon reports signon as floor(connectedSince / 1000)"},{"id":"1143","name":"whoisReducer — channel visibility hides secret (+s) channels the requester is not on"},{"id":"1144","name":"whoisReducer — channel visibility shows secret (+s) channels the requester is also on"},{"id":"1145","name":"whoisReducer — channel visibility hides private (+p) channels the requester is not on"},{"id":"1146","name":"whoisReducer — channel visibility prefixes op channels with @ and voiced channels with +"},{"id":"1147","name":"whoisReducer — channel visibility emits no 319 when no channels are visible"},{"id":"1148","name":"whoisReducer — channel visibility sorts channels alphabetically regardless of input order"},{"id":"1149","name":"whoisReducer — channel visibility skips channels where the target is not actually a member"},{"id":"1150","name":"whoisReducer — errors and edge cases emits 401 then 318 for an unknown nick"},{"id":"1151","name":"whoisReducer — errors and edge cases uses * in replies when the requester has no nick"},{"id":"1152","name":"whoisReducer — errors and edge cases updates the requester lastSeen to ctx.clock.now()"},{"id":"1153","name":"whoisReducer — errors and edge cases emits 401-less 318 when target is undefined and params are empty"},{"id":"1154","name":"whoisReducer — errors and edge cases uses ? fallbacks when target has missing user/host/realname fields"},{"id":"1155","name":"whoisReducer — errors and edge cases uses * fallback for target nick when target has no nick set"}],"source":"import { describe, expect, it } from 'vitest';\nimport { operReducer } from '../../src/commands/oper';\nimport { whoisReducer } from '../../src/commands/whois';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';\nimport { type ChannelState, createChannel } from '../../src/state/channel';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n operCreds: [{ user: 'bob', password: 'oppass' }],\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(10_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nfunction makeTarget(\n id: string,\n nick: string,\n opts: {\n user?: string;\n host?: string;\n realname?: string;\n away?: string;\n oper?: boolean;\n account?: string;\n connectedSince?: number;\n lastSeen?: number;\n } = {},\n): ConnectionState {\n const since = opts.connectedSince ?? 1_000;\n const s = createConnection({\n id,\n connectedSince: since,\n ...(opts.lastSeen !== undefined ? { lastSeen: opts.lastSeen } : {}),\n });\n s.nick = nick;\n s.user = opts.user ?? nick;\n s.host = opts.host ?? 'target.example.net';\n s.realname = opts.realname ?? nick;\n s.registration = 'registered';\n if (opts.away !== undefined) s.away = opts.away;\n if (opts.oper !== undefined) s.userModes.oper = opts.oper;\n if (opts.account !== undefined) s.account = opts.account;\n return s;\n}\n\nfunction makeChan(name: string, opts: { secret?: boolean; private?: boolean } = {}): ChannelState {\n const c = createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });\n if (opts.secret) c.modes.secret = true;\n if (opts.private) c.modes.private = true;\n return c;\n}\n\nfunction addMember(\n chan: ChannelState,\n connId: string,\n nick: string,\n op = false,\n voice = false,\n): void {\n chan.members.set(connId, { conn: connId, nick, op, voice });\n}\n\nconst L = (text: string): RawLine => ({ text });\n\n// ============================================================================\n// whoisReducer — happy path\n// ============================================================================\n\ndescribe('whoisReducer — happy path', () => {\n it('emits 311/312/317/318 for a registered user with no channels', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester, new FakeClock(10_000));\n const target = makeTarget('c2', 'bob', { connectedSince: 4_000, lastSeen: 8_000 });\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [\n L(':irc.example.com 311 alice bob bob target.example.net * :bob'),\n L(':irc.example.com 312 alice bob irc.example.com :ExampleNet'),\n L(':irc.example.com 317 alice bob 2 4 :seconds idle, signon time'),\n ]),\n Effect.send('c1', [L(':irc.example.com 318 alice bob :End of /WHOIS list')]),\n ]);\n });\n\n it('emits 319 RPL_WHOISCHANNELS between 317 and 318 when the target is in channels', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const pub = makeChan('#foo');\n addMember(pub, 'c2', 'bob');\n const pub2 = makeChan('#bar');\n addMember(pub2, 'c2', 'bob', true);\n\n const out = whoisReducer(target, [pub, pub2], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send319 = out.effects[1];\n expect(send319?.tag).toBe('Send');\n if (send319?.tag === 'Send') {\n // Channels sorted alphabetically with prefix sigils.\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :@#bar #foo');\n }\n });\n\n it('emits 330 RPL_WHOISACCOUNT when the target has an account', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob', { account: 'aliceacct' });\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n // 330 appears in the first batch alongside 311/312/317.\n expect(send.lines.some((l) => l.text.includes(' 330 '))).toBe(true);\n expect(send.lines.some((l) => l.text.includes(' aliceacct '))).toBe(true);\n }\n });\n\n it('emits 313 RPL_WHOISOPERATOR when the target is an IRC operator', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n // Grant oper via the real OPER command flow rather than poking state directly.\n const target = makeTarget('c2', 'bob');\n const targetCtx = makeCtx(target);\n operReducer(target, { command: 'OPER', params: ['bob', 'oppass'], tags: {} }, targetCtx);\n expect(target.userModes.oper).toBe(true);\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines.some((l) => l.text.includes(' 313 '))).toBe(true);\n expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);\n }\n });\n\n it('emits 276 RPL_WHOISSECURE when the target connected over TLS (user mode S)', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n target.userModes.tls = true;\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n expect(send?.tag).toBe('Send');\n if (send?.tag === 'Send') {\n expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(true);\n expect(\n send.lines.some(\n (l) => l.text === ':irc.example.com 276 alice bob :is using a secure connection',\n ),\n ).toBe(true);\n }\n });\n\n it('does not emit 276 when the target is not on a secure connection', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n // tls left at its default (false).\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(false);\n }\n });\n});\n\n// ============================================================================\n// whoisReducer — idle/signon math\n// ============================================================================\n\ndescribe('whoisReducer — idle and signon', () => {\n it('reports idle seconds as floor((now - lastSeen) / 1000)', () => {\n const requester = makeConn();\n // clock.now = 10_000ms, target.lastSeen = 8_000ms → 2s idle.\n const ctx = makeCtx(requester, new FakeClock(10_000));\n const target = makeTarget('c2', 'bob', { connectedSince: 4_000, lastSeen: 8_000 });\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n const idle = send.lines.find((l) => l.text.includes(' 317 '));\n expect(idle?.text).toContain(' 317 alice bob 2 4 ');\n }\n });\n\n it('reports signon as floor(connectedSince / 1000)', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob', { connectedSince: 65_000 });\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n const idle = send.lines.find((l) => l.text.includes(' 317 '));\n expect(idle?.text).toContain(' 65 ');\n }\n });\n});\n\n// ============================================================================\n// whoisReducer — channel visibility\n// ============================================================================\n\ndescribe('whoisReducer — channel visibility', () => {\n it('hides secret (+s) channels the requester is not on', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const pub = makeChan('#foo');\n addMember(pub, 'c2', 'bob');\n const sec = makeChan('#secret', { secret: true });\n addMember(sec, 'c2', 'bob');\n\n const out = whoisReducer(target, [pub, sec], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send319 = out.effects[1];\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#foo');\n }\n });\n\n it('shows secret (+s) channels the requester is also on', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const sec = makeChan('#secret', { secret: true });\n addMember(sec, 'c2', 'bob');\n addMember(sec, 'c1', 'alice');\n\n const out = whoisReducer(target, [sec], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send319 = out.effects[1];\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#secret');\n }\n });\n\n it('hides private (+p) channels the requester is not on', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const priv = makeChan('#priv', { private: true });\n addMember(priv, 'c2', 'bob');\n\n const out = whoisReducer(target, [priv], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n // No 319 emitted.\n const send319 = out.effects[1];\n expect(send319?.tag).toBe('Send');\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toContain(' 318 ');\n }\n });\n\n it('prefixes op channels with @ and voiced channels with +', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const opChan = makeChan('#op');\n addMember(opChan, 'c2', 'bob', true);\n const voiceChan = makeChan('#voice');\n addMember(voiceChan, 'c2', 'bob', false, true);\n\n const out = whoisReducer(\n target,\n [opChan, voiceChan],\n { command: 'WHOIS', params: ['bob'] },\n ctx,\n );\n\n const send319 = out.effects[1];\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :@#op +#voice');\n }\n });\n\n it('emits no 319 when no channels are visible', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n // 318 should be the second effect (no 319 in between).\n expect(out.effects.length).toBe(2);\n expect(out.effects[1]?.tag).toBe('Send');\n });\n\n it('sorts channels alphabetically regardless of input order', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const zed = makeChan('#zed');\n addMember(zed, 'c2', 'bob');\n const alpha = makeChan('#alpha');\n addMember(alpha, 'c2', 'bob');\n\n const out = whoisReducer(target, [zed, alpha], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send319 = out.effects[1];\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#alpha #zed');\n }\n });\n\n it('skips channels where the target is not actually a member', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const realChan = makeChan('#real');\n addMember(realChan, 'c2', 'bob');\n const ghostChan = makeChan('#ghost');\n // Target is NOT a member of #ghost.\n\n const out = whoisReducer(\n target,\n [realChan, ghostChan],\n { command: 'WHOIS', params: ['bob'] },\n ctx,\n );\n\n const send319 = out.effects[1];\n if (send319?.tag === 'Send') {\n expect(send319.lines[0]?.text).toBe(':irc.example.com 319 alice bob :#real');\n }\n });\n});\n\n// ============================================================================\n// whoisReducer — errors and edge cases\n// ============================================================================\n\ndescribe('whoisReducer — errors and edge cases', () => {\n it('emits 401 then 318 for an unknown nick', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n\n const out = whoisReducer(undefined, [], { command: 'WHOIS', params: ['ghost'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),\n Effect.send('c1', [L(':irc.example.com 318 alice ghost :End of /WHOIS list')]),\n ]);\n });\n\n it('uses * in replies when the requester has no nick', () => {\n const requester = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(requester);\n const target = makeTarget('c2', 'bob');\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const end = out.effects.at(-1);\n if (end?.tag === 'Send') {\n expect(end.lines[0]?.text).toBe(':irc.example.com 318 * bob :End of /WHOIS list');\n }\n });\n\n it('updates the requester lastSeen to ctx.clock.now()', () => {\n const requester = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(requester, clock);\n const target = makeTarget('c2', 'bob');\n\n whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n expect(requester.lastSeen).toBe(7_700);\n });\n\n it('emits 401-less 318 when target is undefined and params are empty', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n\n const out = whoisReducer(undefined, [], { command: 'WHOIS', params: [] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 318 alice :End of /WHOIS list')]),\n ]);\n });\n\n it('uses ? fallbacks when target has missing user/host/realname fields', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n // Construct a target with only nick set (no user/host/realname).\n const target = createConnection({ id: 'c2', connectedSince: 1_000 });\n target.nick = 'bob';\n target.registration = 'registered';\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n const line311 = send.lines.find((l) => l.text.includes(' 311 '));\n // nick is bob, user/host fall back to ?, realname falls back to nick.\n expect(line311?.text).toBe(':irc.example.com 311 alice bob ? ? * :bob');\n }\n });\n\n it('uses * fallback for target nick when target has no nick set', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n // Target ConnectionState with no nick at all (highly defensive path).\n const target = createConnection({ id: 'c2', connectedSince: 1_000 });\n target.user = 'bob';\n target.host = 'h';\n target.realname = 'Bob';\n target.registration = 'registered';\n\n const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);\n\n const end = out.effects.at(-1);\n if (end?.tag === 'Send') {\n // The 318 line middle param falls back to * when target.nick is undefined.\n expect(end.lines[0]?.text).toBe(':irc.example.com 318 alice * :End of /WHOIS list');\n }\n });\n});\n"},"tests/commands/whowas.test.ts":{"tests":[{"id":"1156","name":"whowasReducer — single nick with history emits 314 RPL_WHOWASUSER then 369 RPL_ENDOFWHOWAS for a recorded nick"},{"id":"1157","name":"whowasReducer — single nick with history updates the requester lastSeen to ctx.clock.now()"},{"id":"1158","name":"whowasReducer — single nick with history uses ? fallbacks when the entry has no user/host and nick as realname"},{"id":"1159","name":"whowasReducer — no match emits 406 ERR_WASNOSUCHNICK then 369 for an unknown nick"},{"id":"1160","name":"whowasReducer — multiple comma-separated nicks emits a 314+369 (or 406+369) block per queried nick in order"},{"id":"1161","name":"whowasReducer — count cap caps the number of 314 lines at the requested count"},{"id":"1162","name":"whowasReducer — count cap emits all matches when count exceeds the available history"},{"id":"1163","name":"whowasReducer — count cap treats a non-positive or malformed count as unlimited"},{"id":"1164","name":"whowasReducer — no arg / 431 emits 431 ERR_NONICKNAMEGIVEN when no nick argument is supplied"},{"id":"1165","name":"whowasReducer — case-insensitive lookup matches the recorded nick regardless of case"},{"id":"1166","name":"whowasReducer — no store bound treats undefined history as no matches (406 + 369, no crash)"},{"id":"1167","name":"whowasReducer — requester without nick uses * as the requester nick in replies"}],"source":"import { describe, expect, it } from 'vitest';\nimport { whowasReducer } from '../../src/commands/whowas';\nimport { Effect } from '../../src/effects';\nimport type { Effect as EffectType, RawLine } from '../../src/effects';\nimport {\n EmptyMotdProvider,\n FakeClock,\n InMemoryNickHistoryStore,\n SequentialIdFactory,\n} from '../../src/ports';\nimport { type ConnectionState, createConnection } from '../../src/state/connection';\nimport { type Ctx, type ServerConfig, buildCtx } from '../../src/types';\n\nconst serverConfig: ServerConfig = {\n serverName: 'irc.example.com',\n networkName: 'ExampleNet',\n maxChannelsPerUser: 30,\n maxTargetsPerCommand: 10,\n maxListEntries: 50,\n nickLen: 30,\n channelLen: 50,\n topicLen: 390,\n quitMessage: 'Client Quit',\n};\n\nfunction makeCtx(conn: ConnectionState, clock = new FakeClock(10_000)): Ctx {\n return buildCtx({\n serverConfig,\n clock,\n ids: new SequentialIdFactory(),\n motd: EmptyMotdProvider,\n connection: conn,\n });\n}\n\nfunction makeConn(id = 'c1', nick = 'alice'): ConnectionState {\n const s = createConnection({ id, connectedSince: 0 });\n s.nick = nick;\n s.user = nick;\n s.host = 'example.com';\n s.realname = 'Alice';\n s.registration = 'registered';\n return s;\n}\n\nconst L = (text: string): RawLine => ({ text });\n\ndescribe('whowasReducer — single nick with history', () => {\n it('emits 314 RPL_WHOWASUSER then 369 RPL_ENDOFWHOWAS for a recorded nick', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'bob',\n connId: 'c2',\n username: 'bob',\n hostname: 'target.example.net',\n realname: 'Bob',\n signoffTime: 5_000,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 314 alice bob bob target.example.net * :Bob')]),\n Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),\n ]);\n });\n\n it('updates the requester lastSeen to ctx.clock.now()', () => {\n const requester = makeConn();\n const clock = new FakeClock(7_700);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });\n\n whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);\n\n expect(requester.lastSeen).toBe(7_700);\n });\n\n it('uses ? fallbacks when the entry has no user/host and nick as realname', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);\n\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toBe(':irc.example.com 314 alice bob ? ? * :bob');\n }\n });\n});\n\ndescribe('whowasReducer — no match', () => {\n it('emits 406 ERR_WASNOSUCHNICK then 369 for an unknown nick', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const history = new InMemoryNickHistoryStore(new FakeClock(10_000));\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['ghost'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),\n Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),\n ]);\n });\n});\n\ndescribe('whowasReducer — multiple comma-separated nicks', () => {\n it('emits a 314+369 (or 406+369) block per queried nick in order', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'bob',\n connId: 'c2',\n username: 'bob',\n hostname: 'h',\n realname: 'Bob',\n signoffTime: 1_000,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob,ghost'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 314 alice bob bob h * :Bob')]),\n Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),\n Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),\n Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),\n ]);\n });\n});\n\ndescribe('whowasReducer — count cap', () => {\n it('caps the number of 314 lines at the requested count', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'bob',\n connId: 'c1',\n username: 'u1',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_000,\n });\n clock.advance(100);\n history.record({\n nick: 'bob',\n connId: 'c2',\n username: 'u2',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_100,\n });\n clock.advance(100);\n history.record({\n nick: 'bob',\n connId: 'c3',\n username: 'u3',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_200,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '1'] }, ctx);\n\n // Only the most-recent entry (c3) is emitted, then the single 369.\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 314 alice bob u3 h * :R')]),\n Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),\n ]);\n });\n\n it('emits all matches when count exceeds the available history', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'bob',\n connId: 'c1',\n username: 'u1',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_000,\n });\n history.record({\n nick: 'bob',\n connId: 'c2',\n username: 'u2',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_100,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '50'] }, ctx);\n\n const lines314 = out.effects.filter((e) => {\n if (e.tag !== 'Send') return false;\n return e.lines.some((l) => l.text.includes(' 314 '));\n });\n expect(lines314).toHaveLength(2);\n });\n\n it('treats a non-positive or malformed count as unlimited', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'bob',\n connId: 'c1',\n username: 'u1',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_000,\n });\n history.record({\n nick: 'bob',\n connId: 'c2',\n username: 'u2',\n hostname: 'h',\n realname: 'R',\n signoffTime: 1_100,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '0'] }, ctx);\n\n const count314 = out.effects.filter((e) => {\n if (e.tag !== 'Send') return false;\n return e.lines.some((l) => l.text.includes(' 314 '));\n });\n expect(count314).toHaveLength(2);\n });\n});\n\ndescribe('whowasReducer — no arg / 431', () => {\n it('emits 431 ERR_NONICKNAMEGIVEN when no nick argument is supplied', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n const history = new InMemoryNickHistoryStore(new FakeClock(10_000));\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: [] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 431 alice :No nickname given')]),\n ]);\n });\n});\n\ndescribe('whowasReducer — case-insensitive lookup', () => {\n it('matches the recorded nick regardless of case', () => {\n const requester = makeConn();\n const clock = new FakeClock(10_000);\n const ctx = makeCtx(requester, clock);\n const history = new InMemoryNickHistoryStore(clock);\n history.record({\n nick: 'Bob',\n connId: 'c2',\n username: 'bob',\n hostname: 'h',\n realname: 'Bob',\n signoffTime: 1_000,\n });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['BOB'] }, ctx);\n\n expect(out.effects[0]?.tag).toBe('Send');\n const send = out.effects[0];\n if (send?.tag === 'Send') {\n expect(send.lines[0]?.text).toContain(' 314 alice Bob ');\n }\n });\n});\n\ndescribe('whowasReducer — no store bound', () => {\n it('treats undefined history as no matches (406 + 369, no crash)', () => {\n const requester = makeConn();\n const ctx = makeCtx(requester);\n\n const out = whowasReducer(undefined, { command: 'WHOWAS', params: ['ghost'] }, ctx);\n\n expect(out.effects).toEqual<"+"EffectType[]>([\n Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),\n Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),\n ]);\n });\n});\n\ndescribe('whowasReducer — requester without nick', () => {\n it('uses * as the requester nick in replies', () => {\n const requester = createConnection({ id: 'c1', connectedSince: 0 });\n const ctx = makeCtx(requester);\n const history = new InMemoryNickHistoryStore(new FakeClock(10_000));\n history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });\n\n const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);\n\n const end = out.effects.at(-1);\n if (end?.tag === 'Send') {\n expect(end.lines[0]?.text).toBe(':irc.example.com 369 * bob :End of WHOWAS');\n }\n });\n});\n"}},"projectRoot":"/Users/timeichholz/personal-projects/shared-projects/CodeProjects/ServerlessIRCd/packages/irc-core","config":{"$schema":"./node_modules/@stryker-mutator/core/schema/stryker-schema.json","_comment":"Mutation-testing spot-check for irc-core/protocol (coverage gate hardening). Targets the parser, serializer, numerics, message framing, batch framing, base64 and outbound helpers. Runs the existing vitest suite against generated mutants and fails the build below MUTATION_SCORE_THRESHOLD=80 (see tools/ci-hardening/src/validate.ts).","packageManager":"npm","testRunner":"vitest","plugins":["@stryker-mutator/vitest-runner"],"coverageAnalysis":"off","mutate":["src/protocol/parser.ts","src/protocol/serializer.ts","src/protocol/numerics.ts","src/protocol/messages.ts","src/protocol/outbound.ts","src/protocol/batch.ts","src/protocol/base64.ts"],"disableTypeChecks":"{src,tests}/**/*.ts","thresholds":{"high":80,"low":60,"break":80},"reporters":["clear-text","html"],"timeoutMS":5000,"concurrency":2,"configFile":"stryker.protocol.conf.json","allowConsoleColors":true,"checkers":[],"checkerNodeArgs":[],"commandRunner":{"command":"npm test"},"clearTextReporter":{"allowColor":true,"allowEmojis":false,"logTests":true,"maxTestsToLog":3,"reportTests":true,"reportMutants":true,"reportScoreTable":true,"skipFull":false},"dashboard":{"baseUrl":"https://dashboard.stryker-mutator.io/api/reports","reportType":"full"},"dryRunOnly":false,"eventReporter":{"baseDir":"reports/mutation/events"},"ignorePatterns":[],"ignoreStatic":false,"incremental":false,"incrementalFile":"reports/stryker-incremental.json","force":false,"fileLogLevel":"off","inPlace":false,"logLevel":"info","maxConcurrentTestRunners":9007199254740991,"maxTestRunnerReuse":0,"mutator":{"plugins":null,"excludedMutations":[]},"appendPlugins":[],"htmlReporter":{"fileName":"reports/mutation/mutation.html"},"jsonReporter":{"fileName":"reports/mutation/mutation.json"},"symlinkNodeModules":true,"tempDirName":".stryker-tmp","cleanTempDir":true,"testRunnerNodeArgs":[],"timeoutFactor":1.5,"dryRunTimeoutMinutes":5,"tsconfigFile":"tsconfig.json","warnings":true,"disableBail":false,"allowEmpty":false,"ignorers":[],"testFiles":[],"vitest":{"related":true}},"framework":{"name":"StrykerJS","version":"9.6.1","branding":{"homepageUrl":"https://stryker-mutator.io","imageUrl":"data:image/svg+xml;utf8,%3Csvg viewBox='0 0 1458 1458' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' clip-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='2'%3E%3Cpath fill='none' d='M0 0h1458v1458H0z'/%3E%3CclipPath id='a'%3E%3Cpath d='M0 0h1458v1458H0z'/%3E%3C/clipPath%3E%3Cg clip-path='url(%23a)'%3E%3Cpath d='M1458 729c0 402.655-326.345 729-729 729S0 1131.655 0 729C0 326.445 326.345 0 729 0s729 326.345 729 729' fill='%23e74c3c' fill-rule='nonzero'/%3E%3Cpath d='M778.349 1456.15L576.6 1254.401l233-105 85-78.668v-64.332l-257-257-44-187-50-208 251.806-82.793L1076.6 389.401l380.14 379.15c-19.681 367.728-311.914 663.049-678.391 687.599z' fill-opacity='.3'/%3E%3Cpath d='M753.4 329.503c41.79 0 74.579 7.83 97.925 25.444 23.571 18.015 41.69 43.956 55.167 77.097l11.662 28.679 165.733-58.183-14.137-32.13c-26.688-60.655-64.896-108.61-114.191-144.011-49.329-35.423-117.458-54.302-204.859-54.302-50.78 0-95.646 7.376-134.767 21.542-40.093 14.671-74.09 34.79-102.239 60.259-28.84 26.207-50.646 57.06-65.496 92.701-14.718 35.052-22.101 72.538-22.101 112.401 0 72.536 20.667 133.294 61.165 182.704 38.624 47.255 98.346 88.037 179.861 121.291 42.257 17.475 78.715 33.125 109.227 46.994 27.193 12.361 49.294 26.124 66.157 41.751 15.309 14.186 26.497 30.584 33.63 49.258 7.721 20.214 11.16 45.69 11.16 76.402 0 28.021-4.251 51.787-13.591 71.219-8.832 18.374-20.171 33.178-34.523 44.219-14.787 11.374-31.193 19.591-49.393 24.466-19.68 5.359-39.14 7.993-58.69 7.993-29.359 0-54.387-3.407-75.182-10.747-20.112-7.013-37.144-16.144-51.259-27.486-13.618-11.009-24.971-23.766-33.744-38.279-9.64-15.8-17.272-31.924-23.032-48.408l-10.965-31.376-161.669 60.585 10.734 30.124c10.191 28.601 24.197 56.228 42.059 82.748 18.208 27.144 41.322 51.369 69.525 72.745 27.695 21.075 60.904 38.218 99.481 51.041 37.777 12.664 82.004 19.159 132.552 19.159 49.998 0 95.818-8.321 137.611-24.622 42.228-16.471 78.436-38.992 108.835-67.291 30.719-28.597 54.631-62.103 71.834-100.642 17.263-38.56 25.923-79.392 25.923-122.248 0-54.339-8.368-100.37-24.208-138.32-16.29-38.759-38.252-71.661-65.948-98.797-26.965-26.418-58.269-48.835-93.858-67.175-33.655-17.241-69.196-33.11-106.593-47.533-35.934-13.429-65.822-26.601-89.948-39.525-22.153-11.868-40.009-24.21-53.547-37.309-11.429-11.13-19.83-23.678-24.718-37.664-5.413-15.49-7.98-33.423-7.98-53.577 0-40.883 11.293-71.522 37.086-90.539 28.443-20.825 64.985-30.658 109.311-30.658z' fill='%23f1c40f' fill-rule='nonzero'/%3E%3Cpath d='M720 0h18v113h-18zM1458 738v-18h-113v18h113zM720 1345h18v113h-18zM113 738v-18H0v18h113z'/%3E%3C/g%3E%3C/svg%3E"},"dependencies":{"typescript":"5.9.3"}}};
335
+ function updateTheme() {
336
+ document.body.style.backgroundColor = app.themeBackgroundColor;
337
+ }
338
+ app.addEventListener('theme-changed', updateTheme);
339
+ updateTheme();
340
+ </script>
341
+ </body>
342
+ </html>