magus-cli 0.0.0-placeholder → 7.1.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 (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * REGRESSION: every installed plugin rendered "stale — reinstall" at once.
3
+ *
4
+ * `updateInstalledPluginsRegistry` re-copies a plugin's files out of the
5
+ * marketplace clone on every in-place update, but used to carry the pre-update
6
+ * `gitCommitSha` forward verbatim. The recorded commit therefore named whichever
7
+ * release the plugin was FIRST installed from — measured 2026-08-22 on one
8
+ * machine: dev claimed 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
9
+ * against 1.1.3 — and the subtree diff across those releases is always
10
+ * non-empty. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
11
+ *
12
+ * Hermetic: exercises the provenance rule directly, with a real git repo. The
13
+ * registry write itself resolves through os.homedir() and is not sandboxable
14
+ * without new test infrastructure, so the decision is tested where it lives.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { spawnSync } from "node:child_process";
19
+ import { promises as fs } from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { resolveCacheProvenance } from "../services/claude-settings.js";
23
+
24
+ let repo: string;
25
+ let plain: string;
26
+
27
+ const git = (args: string[]) =>
28
+ spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
29
+ cwd: repo,
30
+ encoding: "utf-8",
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ repo = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-repo-"));
35
+ plain = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-plain-"));
36
+ await fs.mkdir(path.join(repo, "plugins", "alpha"), { recursive: true });
37
+ await fs.writeFile(path.join(repo, "plugins", "alpha", "plugin.json"), "{}");
38
+ git(["init", "-q", "-b", "main"]);
39
+ git(["config", "user.email", "probe@example.invalid"]);
40
+ git(["config", "user.name", "probe"]);
41
+ git(["add", "-A"]);
42
+ git(["commit", "-q", "-m", "release"]);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await fs.rm(repo, { recursive: true, force: true });
47
+ await fs.rm(plain, { recursive: true, force: true });
48
+ });
49
+
50
+ describe("resolveCacheProvenance", () => {
51
+ test("stamps the source repo's HEAD when files were copied", async () => {
52
+ const head = git(["rev-parse", "HEAD"]).stdout.trim();
53
+ const stale = "0".repeat(40);
54
+
55
+ expect(
56
+ await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
57
+ ).toBe(head);
58
+ });
59
+
60
+ test("does NOT carry a stale sha forward after a successful copy", async () => {
61
+ // The bug in one line: the old code returned `stale` here, so the
62
+ // recorded commit fell further behind with every release.
63
+ const stale = "0".repeat(40);
64
+ expect(
65
+ await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
66
+ ).not.toBe(stale);
67
+ });
68
+
69
+ test("keeps the previous sha when nothing was copied", async () => {
70
+ // A failed copy leaves the cache untouched, so the old sha still
71
+ // describes it. Advancing it would claim the cache holds newer files
72
+ // than it does, which hides real drift.
73
+ const previous = "a".repeat(40);
74
+ expect(await resolveCacheProvenance(null, previous)).toBe(previous);
75
+ expect(await resolveCacheProvenance(null, undefined)).toBeUndefined();
76
+ });
77
+
78
+ test("records no sha for a source outside any git repo", async () => {
79
+ // Directory-type marketplaces have no commit to name. Inventing one
80
+ // would assert a provenance that is false; content-drift reads an
81
+ // absent sha as "cannot answer" and stays silent.
82
+ expect(await resolveCacheProvenance(plain, "b".repeat(40))).toBeUndefined();
83
+ });
84
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ extractSection,
5
+ normalizeVersion,
6
+ slugFor,
7
+ stripSectionHeader,
8
+ toStyleFile,
9
+ } from "../../scripts/capture-builtin.ts";
10
+ import { splitFrontmatter } from "../services/styles-manager.js";
11
+
12
+ const MARKER = "# Output Style: X";
13
+
14
+ const BASELINE = "# First\nalpha\n\n# Second\nbeta";
15
+
16
+ describe("extractSection", () => {
17
+ test("stops at the next structural heading", () => {
18
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# Second\nbeta`;
19
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
20
+ });
21
+
22
+ test("keeps an H1 that belongs to the style, not to the harness", () => {
23
+ // Explanatory really does ship "# Explanatory Style Active" inside its
24
+ // own body. Splitting on the next "# " truncates it.
25
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# X Style Active\nmore\n\n# Second\nbeta`;
26
+ const got = extractSection(BASELINE, styled, MARKER);
27
+ expect(got).toContain("# X Style Active");
28
+ expect(got).toContain("more");
29
+ expect(got).not.toContain("beta");
30
+ });
31
+
32
+ test("does not truncate when the section ends the way the tail ends", () => {
33
+ // A character-level suffix walk cut Explanatory mid-sentence here.
34
+ const styled = `# First\nalpha\n\n${MARKER}\nProvide them as you write code.\n\n# Second\nbeta`;
35
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(
36
+ `${MARKER}\nProvide them as you write code.`,
37
+ );
38
+ });
39
+
40
+ test("runs to the end when the section is last", () => {
41
+ const styled = `# First\nalpha\n\n${MARKER}\nbody`;
42
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
43
+ });
44
+
45
+ test("returns empty when the marker is absent", () => {
46
+ expect(extractSection(BASELINE, BASELINE, MARKER)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("stripSectionHeader", () => {
51
+ test("removes the harness header and keeps H1s belonging to the style", () => {
52
+ const block =
53
+ "# Output Style: Explanatory\nBody line.\n\n# Explanatory Style Active\n\nMore.";
54
+ const body = stripSectionHeader(block, "Explanatory");
55
+ expect(body.startsWith("Body line.")).toBe(true);
56
+ // A heading-based split would have cut here; the style owns this H1.
57
+ expect(body).toContain("# Explanatory Style Active");
58
+ });
59
+
60
+ test("passes the block through when the header is absent", () => {
61
+ expect(stripSectionHeader(" no header here ", "Explanatory")).toBe(
62
+ "no header here",
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("toStyleFile", () => {
68
+ test("produces an importable file with coding instructions kept", () => {
69
+ const file = toStyleFile("Explanatory", "Body text.", "2.1.233");
70
+ const { frontmatter, body } = splitFrontmatter(file);
71
+ expect(frontmatter.name).toBe("builtin-explanatory");
72
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
73
+ expect(frontmatter.description).toBe(
74
+ "Captured built-in output style: Explanatory",
75
+ );
76
+ expect(frontmatter["captured-from"]).toBe("2.1.233");
77
+ expect(frontmatter["captured-style"]).toBe("Explanatory");
78
+ expect(body).toBe("Body text.");
79
+ });
80
+
81
+ test("slugs a multi-word style name into a usable filename", () => {
82
+ expect(slugFor("Explanatory")).toBe("builtin-explanatory");
83
+ expect(slugFor("Deep Research")).toBe("builtin-deep-research");
84
+ });
85
+ });
86
+
87
+ describe("normalizeVersion", () => {
88
+ test("reduces the CLI banner to a bare version so --check can compare it", () => {
89
+ expect(normalizeVersion("2.1.233 (Claude Code)")).toBe("2.1.233");
90
+ expect(normalizeVersion("2.1.233")).toBe("2.1.233");
91
+ });
92
+ });
@@ -0,0 +1,309 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ CATALOG_TTL_MS,
7
+ clearCachedCatalogs,
8
+ clearStoredCooldown,
9
+ readCachedCatalog,
10
+ readStoredCooldowns,
11
+ resetCatalogCacheMemo,
12
+ writeCachedCatalog,
13
+ writeStoredCooldown,
14
+ } from "../services/catalog-cache-store";
15
+ import {
16
+ cooldownFor,
17
+ hydrateGitHubBudget,
18
+ recordRateLimit,
19
+ resetGitHubBudget,
20
+ } from "../services/github-budget";
21
+ import type { MarketplacePlugin } from "../services/marketplace-catalog";
22
+
23
+ /**
24
+ * These cover the cross-LAUNCH behaviour, which is the only kind that matters for
25
+ * a TUI. Measured before this store existed, two consecutive processes:
26
+ *
27
+ * [process-1] 6 HTTP requests in 13.32s
28
+ * [process-2] 6 HTTP requests in 13.01s
29
+ *
30
+ * Every cache was a module-level Map, so a relaunch re-fetched all six catalogs —
31
+ * and, worse, forgot that the previous launch had just been rate-limited and spent
32
+ * six more requests rediscovering it.
33
+ *
34
+ * A fresh module registry is the closest a unit test gets to a fresh process, so
35
+ * `resetGitHubBudget()` + `resetCatalogCacheMemo()` stand in for a relaunch: they
36
+ * drop all in-memory state while leaving the file on disk.
37
+ */
38
+
39
+ let configDir: string;
40
+ let prevConfigDir: string | undefined;
41
+
42
+ const NOW = 1_786_980_000_000;
43
+
44
+ function plugin(name: string, version: string): MarketplacePlugin {
45
+ return { name, version, description: "" };
46
+ }
47
+
48
+ beforeEach(() => {
49
+ configDir = fs.mkdtempSync(path.join(os.tmpdir(), "catalog-cache-"));
50
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
51
+ process.env.CLAUDE_CONFIG_DIR = configDir;
52
+ resetCatalogCacheMemo();
53
+ resetGitHubBudget();
54
+ });
55
+
56
+ afterEach(() => {
57
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
58
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
59
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
60
+ fs.rmSync(configDir, { recursive: true, force: true });
61
+ });
62
+
63
+ describe("catalog cache — survives a relaunch", () => {
64
+ it("serves a catalog written by a previous process", async () => {
65
+ await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
66
+
67
+ // Stand in for a relaunch: in-memory state gone, file intact.
68
+ resetCatalogCacheMemo();
69
+
70
+ const cached = await readCachedCatalog("magus");
71
+ expect(cached).not.toBeNull();
72
+ expect(cached?.plugins).toHaveLength(1);
73
+ expect(cached?.plugins[0]?.version).toBe("4.1.6");
74
+ expect(cached?.source).toBe("remote");
75
+ });
76
+
77
+ it("keeps the source, so a cached answer is still labelled honestly", async () => {
78
+ // A `remote-git` answer stays `remote-git` after a round trip through disk.
79
+ // Flattening it to "cached" would lose the authoritative/unverified
80
+ // distinction the whole fix rests on.
81
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote-git");
82
+ resetCatalogCacheMemo();
83
+ expect((await readCachedCatalog("magus"))?.source).toBe("remote-git");
84
+ });
85
+
86
+ it("expires once past the TTL", async () => {
87
+ await writeCachedCatalog(
88
+ "magus",
89
+ [plugin("terminal", "4.1.6")],
90
+ "remote",
91
+ NOW,
92
+ );
93
+ resetCatalogCacheMemo();
94
+
95
+ expect(
96
+ await readCachedCatalog("magus", NOW + CATALOG_TTL_MS - 1000),
97
+ ).not.toBeNull();
98
+ expect(
99
+ await readCachedCatalog("magus", NOW + CATALOG_TTL_MS + 1000),
100
+ ).toBeNull();
101
+ });
102
+
103
+ it("ignores an entry stamped in the future", async () => {
104
+ // A clock that jumped backwards must not pin a cache entry forever.
105
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote", NOW);
106
+ resetCatalogCacheMemo();
107
+ expect(await readCachedCatalog("magus", NOW - 60_000)).toBeNull();
108
+ });
109
+
110
+ it("never caches an empty catalog", async () => {
111
+ // An empty list is indistinguishable from an unclassified failure. Caching it
112
+ // would serve "this marketplace has no plugins" for an hour — the original bug
113
+ // with a longer memory.
114
+ await writeCachedCatalog("magus", [], "remote");
115
+ resetCatalogCacheMemo();
116
+ expect(await readCachedCatalog("magus")).toBeNull();
117
+ });
118
+
119
+ it("is dropped by an explicit refresh", async () => {
120
+ await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
121
+ await clearCachedCatalogs();
122
+ resetCatalogCacheMemo();
123
+ expect(await readCachedCatalog("magus")).toBeNull();
124
+ });
125
+
126
+ it("survives a corrupt cache file instead of throwing", async () => {
127
+ fs.writeFileSync(
128
+ path.join(configDir, "magus-catalog-cache.json"),
129
+ "{ not json",
130
+ );
131
+ resetCatalogCacheMemo();
132
+ expect(await readCachedCatalog("magus")).toBeNull();
133
+
134
+ // And it must recover: a bad file cannot poison later writes.
135
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
136
+ resetCatalogCacheMemo();
137
+ expect(await readCachedCatalog("magus")).not.toBeNull();
138
+ });
139
+ });
140
+
141
+ describe("rate-limit cooldown — survives a relaunch", () => {
142
+ /**
143
+ * Anchored to the real clock, not the fixed NOW the catalog tests use.
144
+ * `hydrateGitHubBudget()` takes no clock parameter — it filters expired entries
145
+ * against `Date.now()`, exactly as it does in production — so a cooldown stamped
146
+ * relative to a fixed past NOW would be discarded as expired before the
147
+ * assertion ever ran.
148
+ */
149
+ let base: number;
150
+ beforeEach(() => {
151
+ base = Date.now();
152
+ });
153
+
154
+ /**
155
+ * Wait until a write-behind cooldown has actually reached disk.
156
+ *
157
+ * This used to be `setTimeout(80)` — a guess at how long an unawaited write
158
+ * takes, which is not a synchronisation point. `recordRateLimit` persists
159
+ * fire-and-forget by design (`github-budget.ts` explains why), so on a loaded
160
+ * runner the write had not landed when the next `hydrateGitHubBudget()` read,
161
+ * and the restored strike count came back low. That failed CI as
162
+ * `Expected: 3, Received: 2` on Linux while passing 15/15 on a quiet macOS
163
+ * laptop — the signature of a timing guess, not of a real defect.
164
+ *
165
+ * Polling the store itself removes the guess: it returns as soon as the value
166
+ * is observable, and only gives up after a deadline no healthy machine
167
+ * reaches. `expected` is the strike count the caller just recorded; passing
168
+ * nothing waits for any persisted entry at all.
169
+ */
170
+ const flush = async (host: string, expected?: number): Promise<void> => {
171
+ const deadline = Date.now() + 5000;
172
+ for (;;) {
173
+ // `base` keeps the read on the same clock the writes used: cooldowns are
174
+ // dropped on read once expired, so reading with a later `now` could hide
175
+ // an entry that is genuinely there.
176
+ const stored = (await readStoredCooldowns(base))[host];
177
+ if (stored && (expected === undefined || stored.strikes >= expected)) {
178
+ return;
179
+ }
180
+ if (Date.now() > deadline) {
181
+ throw new Error(
182
+ `cooldown for ${host} never reached disk (wanted strikes >= ${expected ?? "any"}, got ${stored?.strikes ?? "nothing"})`,
183
+ );
184
+ }
185
+ await new Promise((r) => setTimeout(r, 10));
186
+ }
187
+ };
188
+
189
+ // Teardown hygiene, NOT a synchronisation point — which is why it is a plain
190
+ // sleep and `flush` is not. Its only job is to let an in-flight write land
191
+ // before the temp config dir is removed; a write that arrives afterwards
192
+ // recreates a file in a deleted directory and harms nothing. Polling for a
193
+ // specific host here would be wrong: several tests in this block end with
194
+ // nothing on disk on purpose.
195
+ afterEach(() => new Promise((r) => setTimeout(r, 50)));
196
+
197
+ it("is still in force in the next process", async () => {
198
+ // THE defect this store exists to fix. Without persistence, launch two fired
199
+ // six more doomed requests at a host it had already been refused by.
200
+ recordRateLimit("raw.githubusercontent.com", new Headers(), base);
201
+ await flush("raw.githubusercontent.com");
202
+
203
+ resetGitHubBudget();
204
+ resetCatalogCacheMemo();
205
+ expect(cooldownFor("raw.githubusercontent.com", base)).toBeNull(); // memory only
206
+
207
+ await hydrateGitHubBudget();
208
+ const restored = cooldownFor("raw.githubusercontent.com", base);
209
+ expect(restored).not.toBeNull();
210
+ expect(restored?.until).toBeGreaterThan(base);
211
+ });
212
+
213
+ it("keeps escalating the backoff across relaunches", async () => {
214
+ // Without the strike count surviving, every relaunch restarts the backoff at
215
+ // 30s — so a tool that is relaunched often never actually backs off, which is
216
+ // the behaviour that got the IP rate-limited in the first place.
217
+ //
218
+ // Written as one strike per launch, which is the real shape: a launch makes its
219
+ // requests, learns it is limited once, and exits.
220
+ const first = recordRateLimit("raw.githubusercontent.com", undefined, base);
221
+ expect(first.strikes).toBe(1);
222
+ await flush("raw.githubusercontent.com", 1);
223
+
224
+ resetGitHubBudget();
225
+ resetCatalogCacheMemo();
226
+ await hydrateGitHubBudget();
227
+
228
+ const second = recordRateLimit(
229
+ "raw.githubusercontent.com",
230
+ undefined,
231
+ base,
232
+ );
233
+ expect(second.strikes).toBe(2);
234
+ expect(second.until).toBeGreaterThan(first.until);
235
+ await flush("raw.githubusercontent.com", 2);
236
+
237
+ resetGitHubBudget();
238
+ resetCatalogCacheMemo();
239
+ await hydrateGitHubBudget();
240
+
241
+ const third = recordRateLimit("raw.githubusercontent.com", undefined, base);
242
+ expect(third.strikes).toBe(3);
243
+ expect(third.until).toBeGreaterThan(second.until);
244
+ });
245
+
246
+ it("preserves whether the timing was GitHub's or ours", async () => {
247
+ // `exact` decides between "resets in 4m" and "next attempt in 4m". Losing it
248
+ // across a relaunch would make the UI state a claim GitHub never made.
249
+ recordRateLimit(
250
+ "api.github.com",
251
+ new Headers({ "retry-after": "600" }),
252
+ base,
253
+ );
254
+ await flush("api.github.com");
255
+
256
+ resetGitHubBudget();
257
+ await hydrateGitHubBudget();
258
+ expect(cooldownFor("api.github.com", base)?.exact).toBe(true);
259
+ });
260
+
261
+ it("drops an expired cooldown rather than restoring it", async () => {
262
+ await writeStoredCooldown("raw.githubusercontent.com", {
263
+ until: NOW - 1000,
264
+ exact: false,
265
+ strikes: 4,
266
+ });
267
+ resetCatalogCacheMemo();
268
+
269
+ // Expired entries are not returned, so a stale strike count from yesterday
270
+ // cannot make today's first failure back off for ten minutes.
271
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
272
+ });
273
+
274
+ it("is cleared for a host that recovers", async () => {
275
+ await writeStoredCooldown("raw.githubusercontent.com", {
276
+ until: NOW + 60_000,
277
+ exact: false,
278
+ strikes: 1,
279
+ });
280
+ await clearStoredCooldown("raw.githubusercontent.com");
281
+ resetCatalogCacheMemo();
282
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
283
+ });
284
+
285
+ it("keeps catalogs when only the cooldown is cleared, and vice versa", async () => {
286
+ // They share one file; a write to either must not clobber the other.
287
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
288
+ await writeStoredCooldown("raw.githubusercontent.com", {
289
+ until: NOW + 60_000,
290
+ exact: false,
291
+ strikes: 1,
292
+ });
293
+
294
+ await clearStoredCooldown("raw.githubusercontent.com");
295
+ resetCatalogCacheMemo();
296
+ expect(await readCachedCatalog("magus")).not.toBeNull();
297
+
298
+ await writeStoredCooldown("raw.githubusercontent.com", {
299
+ until: NOW + 60_000,
300
+ exact: false,
301
+ strikes: 1,
302
+ });
303
+ await clearCachedCatalogs();
304
+ resetCatalogCacheMemo();
305
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([
306
+ "raw.githubusercontent.com",
307
+ ]);
308
+ });
309
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { MarketplaceFetchFailure } from "../services/marketplace-fetcher";
3
+ import { buildCatalogNoticeText } from "../ui/adapters/catalogNotice";
4
+
5
+ /**
6
+ * The banner is the only thing standing between the user and a screen that
7
+ * silently claims every plugin is up to date. So it has three obligations:
8
+ * appear whenever a check did not actually happen, stay silent when it did or
9
+ * when the state is intended, and stay legible either way.
10
+ *
11
+ * "Stay silent when intended" is not politeness. `autoUpdate: false` and a clone
12
+ * behind its remote are deliberate policy here — plugins are not auto-updated.
13
+ * An earlier version warned about both and suggested the command that would undo
14
+ * the policy, which trains the user to ignore the line.
15
+ */
16
+
17
+ const NOW = 1_786_980_000_000;
18
+
19
+ function failure(
20
+ marketplace: string,
21
+ kind: MarketplaceFetchFailure["kind"] = "rate-limited",
22
+ retryAt?: number,
23
+ ): MarketplaceFetchFailure {
24
+ return { marketplace, kind, detail: `${kind} detail`, retryAt };
25
+ }
26
+
27
+ describe("buildCatalogNoticeText", () => {
28
+ it("says nothing when every catalog was actually checked", () => {
29
+ // The negative control. A banner that is always on carries no information.
30
+ expect(
31
+ buildCatalogNoticeText({
32
+ failures: [],
33
+ unverifiedPlugins: 0,
34
+ width: 120,
35
+ now: NOW,
36
+ }),
37
+ ).toBeNull();
38
+ });
39
+
40
+ it("names the reason, because it determines what the user should do", () => {
41
+ const rate = buildCatalogNoticeText({
42
+ failures: [failure("magus")],
43
+ unverifiedPlugins: 3,
44
+ width: 120,
45
+ now: NOW,
46
+ });
47
+ expect(rate).toContain("GitHub rate limit");
48
+
49
+ const slow = buildCatalogNoticeText({
50
+ failures: [failure("magus", "timeout")],
51
+ unverifiedPlugins: 3,
52
+ width: 120,
53
+ now: NOW,
54
+ });
55
+ expect(slow).toContain("GitHub timed out");
56
+ });
57
+
58
+ it("names one or two failing marketplaces but only counts many", () => {
59
+ const few = buildCatalogNoticeText({
60
+ failures: [failure("magus"), failure("magus-alpha")],
61
+ unverifiedPlugins: 0,
62
+ width: 160,
63
+ now: NOW,
64
+ });
65
+ expect(few).toContain("magus-alpha");
66
+
67
+ const many = buildCatalogNoticeText({
68
+ failures: ["a", "b", "c", "d", "e", "f"].map((n) => failure(n)),
69
+ unverifiedPlugins: 0,
70
+ width: 160,
71
+ now: NOW,
72
+ });
73
+ expect(many).toContain("6 catalogs unchecked");
74
+ });
75
+
76
+ it("counts down to the retry instead of saying 'later'", () => {
77
+ // Answering "when can I try again" is the point. Guessing costs another
78
+ // request against the very limit the user is waiting out.
79
+ const line = buildCatalogNoticeText({
80
+ failures: [failure("magus", "rate-limited", NOW + 240_000)],
81
+ unverifiedPlugins: 17,
82
+ width: 160,
83
+ now: NOW,
84
+ }) as string;
85
+ expect(line).toContain("retrying in 4m");
86
+ });
87
+
88
+ it("uses the soonest retry when several catalogs are waiting", () => {
89
+ const line = buildCatalogNoticeText({
90
+ failures: [
91
+ failure("magus", "rate-limited", NOW + 600_000),
92
+ failure("magus-alpha", "rate-limited", NOW + 45_000),
93
+ ],
94
+ unverifiedPlugins: 2,
95
+ width: 160,
96
+ now: NOW,
97
+ }) as string;
98
+ expect(line).toContain("retrying in 45s");
99
+ });
100
+
101
+ it("falls back to the manual hint when no retry time is known", () => {
102
+ // raw.githubusercontent.com sends no retry-after, so for some failures there
103
+ // is genuinely no time to show. Inventing one would be worse than omitting it.
104
+ const line = buildCatalogNoticeText({
105
+ failures: [failure("magus", "network")],
106
+ unverifiedPlugins: 1,
107
+ width: 160,
108
+ now: NOW,
109
+ }) as string;
110
+ expect(line).toContain("r to retry");
111
+ expect(line).not.toContain("retrying in");
112
+ });
113
+
114
+ it("ignores a retry time that has already passed", () => {
115
+ const line = buildCatalogNoticeText({
116
+ failures: [failure("magus", "rate-limited", NOW - 5_000)],
117
+ unverifiedPlugins: 1,
118
+ width: 160,
119
+ now: NOW,
120
+ }) as string;
121
+ expect(line).toContain("r to retry");
122
+ });
123
+
124
+ it("keeps a retry affordance at every width, however narrow", () => {
125
+ const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
126
+ failure(n, "rate-limited", NOW + 90_000),
127
+ );
128
+ for (const width of [40, 60, 80, 100, 120, 200]) {
129
+ const line = buildCatalogNoticeText({
130
+ failures,
131
+ unverifiedPlugins: 331,
132
+ width,
133
+ now: NOW,
134
+ }) as string;
135
+ expect(line).not.toBeNull();
136
+ expect(line).toContain("retrying in");
137
+ }
138
+ });
139
+
140
+ it("never renders wider than the pane", () => {
141
+ const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
142
+ failure(n, "rate-limited", NOW + 90_000),
143
+ );
144
+ for (const width of [40, 60, 80, 100, 120, 200]) {
145
+ const line = buildCatalogNoticeText({
146
+ failures,
147
+ unverifiedPlugins: 331,
148
+ width,
149
+ now: NOW,
150
+ }) as string;
151
+ // +1 for `⚠` rendering double-width while String.length counts it once.
152
+ expect(line.length + 1).toBeLessThanOrEqual(width);
153
+ }
154
+ });
155
+ });