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.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +524 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +248 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +340 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +345 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +310 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1396 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1564 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +657 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +245 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +847 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +377 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +820 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +535 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1423 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +440 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +149 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
3
|
+
import {
|
|
4
|
+
fetchPluginContents,
|
|
5
|
+
splitSkillDocument,
|
|
6
|
+
} from "../services/skills-manager.js";
|
|
7
|
+
import type { Marketplace } from "../types/index.js";
|
|
8
|
+
import {
|
|
9
|
+
buildPluginBrowserItems,
|
|
10
|
+
pluginContentsRepo,
|
|
11
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
12
|
+
import type { PluginContentsEntry } from "../ui/state/types.js";
|
|
13
|
+
|
|
14
|
+
// ─── fetchPluginContents ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const realFetch = globalThis.fetch;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Serve one canned git-tree response. `fetchGitTree` caches per repo for the
|
|
20
|
+
* life of the process, so every test must use a repo name of its own.
|
|
21
|
+
*/
|
|
22
|
+
function stubTree(paths: string[]): void {
|
|
23
|
+
globalThis.fetch = (async () =>
|
|
24
|
+
new Response(
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
sha: "deadbeef",
|
|
27
|
+
truncated: false,
|
|
28
|
+
tree: paths.map((path) => ({
|
|
29
|
+
path,
|
|
30
|
+
mode: "100644",
|
|
31
|
+
type: "blob",
|
|
32
|
+
sha: `sha-${path}`,
|
|
33
|
+
})),
|
|
34
|
+
}),
|
|
35
|
+
{ status: 200, headers: { ETag: '"x"' } },
|
|
36
|
+
)) as unknown as typeof fetch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
globalThis.fetch = realFetch;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("fetchPluginContents", () => {
|
|
44
|
+
it("counts a grouped skills/ tree once per skill, not once per group", async () => {
|
|
45
|
+
stubTree([
|
|
46
|
+
"README.md",
|
|
47
|
+
"skills/engineering/code-review/SKILL.md",
|
|
48
|
+
"skills/engineering/tdd/SKILL.md",
|
|
49
|
+
"skills/writing/prd/SKILL.md",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const skills = await fetchPluginContents("fake/grouped", "./");
|
|
53
|
+
|
|
54
|
+
expect(skills.map((s) => s.name)).toEqual(["code-review", "tdd", "prd"]);
|
|
55
|
+
expect(skills.every((s) => s.kind === "skill")).toBe(true);
|
|
56
|
+
expect(skills[0].group).toBe("engineering");
|
|
57
|
+
expect(skills[2].group).toBe("writing");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("ignores harness mirrors and translated docs trees", async () => {
|
|
61
|
+
// everything-claude-code's real shape: 286 skills under skills/, mirrored
|
|
62
|
+
// into four harness directories and six docs/<lang>/ trees for 898 files.
|
|
63
|
+
stubTree([
|
|
64
|
+
"skills/api-design/SKILL.md",
|
|
65
|
+
"skills/agent-sort/SKILL.md",
|
|
66
|
+
".agents/skills/api-design/SKILL.md",
|
|
67
|
+
".kiro/skills/api-design/SKILL.md",
|
|
68
|
+
".cursor/skills/api-design/SKILL.md",
|
|
69
|
+
"docs/ja-JP/skills/api-design/SKILL.md",
|
|
70
|
+
"docs/zh-CN/skills/api-design/SKILL.md",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const skills = await fetchPluginContents("fake/mirrored", "./");
|
|
74
|
+
|
|
75
|
+
expect(skills.map((s) => s.name)).toEqual(["agent-sort", "api-design"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("finds a plugin that is one skill at its root", async () => {
|
|
79
|
+
// blader/humanizer: a root SKILL.md and no skills/ directory at all.
|
|
80
|
+
stubTree([".claude-plugin/plugin.json", "SKILL.md", "README.md"]);
|
|
81
|
+
|
|
82
|
+
const skills = await fetchPluginContents("blader/humanizer", "./");
|
|
83
|
+
|
|
84
|
+
expect(skills).toHaveLength(1);
|
|
85
|
+
expect(skills[0].name).toBe("humanizer");
|
|
86
|
+
expect(skills[0].repoPath).toBe("SKILL.md");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("scopes to one plugin's subtree in a multi-plugin marketplace", async () => {
|
|
90
|
+
stubTree([
|
|
91
|
+
"plugins/dev/skills/frontend/design-system/SKILL.md",
|
|
92
|
+
"plugins/dev/skills/backend/db-branching/SKILL.md",
|
|
93
|
+
"plugins/designer/skills/ui-analyse/SKILL.md",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const skills = await fetchPluginContents("fake/multi", "./plugins/dev");
|
|
97
|
+
|
|
98
|
+
expect(skills.map((s) => s.name)).toEqual([
|
|
99
|
+
"db-branching",
|
|
100
|
+
"design-system",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still lists commands and agents for a plugin with no skills", async () => {
|
|
105
|
+
stubTree(["agents/reviewer.md", "commands/go.md"]);
|
|
106
|
+
|
|
107
|
+
const found = await fetchPluginContents("fake/no-skills", "./");
|
|
108
|
+
|
|
109
|
+
expect(found.filter((c) => c.kind === "skill")).toEqual([]);
|
|
110
|
+
expect(found.map((c) => `${c.kind}:${c.name}`)).toEqual([
|
|
111
|
+
"command:go",
|
|
112
|
+
"agent:reviewer",
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ─── splitSkillDocument ──────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("splitSkillDocument", () => {
|
|
120
|
+
it("keeps frontmatter in file order so description stays near the top", () => {
|
|
121
|
+
const { frontmatter } = splitSkillDocument(
|
|
122
|
+
[
|
|
123
|
+
"---",
|
|
124
|
+
"name: code-review",
|
|
125
|
+
"description: Reviews a diff for correctness",
|
|
126
|
+
"allowed-tools: Read, Grep",
|
|
127
|
+
"---",
|
|
128
|
+
"",
|
|
129
|
+
"# Code review",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(frontmatter.map((f) => f.key)).toEqual([
|
|
134
|
+
"name",
|
|
135
|
+
"description",
|
|
136
|
+
"allowed-tools",
|
|
137
|
+
]);
|
|
138
|
+
expect(frontmatter[1].value).toBe("Reviews a diff for correctness");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("flattens both list shapes to one display string", () => {
|
|
142
|
+
const { frontmatter } = splitSkillDocument(
|
|
143
|
+
[
|
|
144
|
+
"---",
|
|
145
|
+
'tags: ["tdd", "testing"]',
|
|
146
|
+
"allowed-tools:",
|
|
147
|
+
" - Read",
|
|
148
|
+
" - Bash",
|
|
149
|
+
"---",
|
|
150
|
+
"body",
|
|
151
|
+
].join("\n"),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(frontmatter[0]).toEqual({ key: "tags", value: "tdd, testing" });
|
|
155
|
+
expect(frontmatter[1]).toEqual({
|
|
156
|
+
key: "allowed-tools",
|
|
157
|
+
value: "Read, Bash",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("separates the body and counts its lines", () => {
|
|
162
|
+
const doc = splitSkillDocument(
|
|
163
|
+
["---", "name: x", "---", "", "# Title", "", "Body text.", ""].join("\n"),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(doc.body).toBe("# Title\n\nBody text.");
|
|
167
|
+
expect(doc.bodyLines).toBe(3);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("treats a file with no frontmatter as all body", () => {
|
|
171
|
+
const doc = splitSkillDocument("# Just markdown\n\nNo frontmatter here.");
|
|
172
|
+
|
|
173
|
+
expect(doc.frontmatter).toEqual([]);
|
|
174
|
+
expect(doc.body).toBe("# Just markdown\n\nNo frontmatter here.");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("reports an empty body rather than a phantom line", () => {
|
|
178
|
+
const doc = splitSkillDocument("---\nname: x\ndescription: y\n---\n");
|
|
179
|
+
|
|
180
|
+
expect(doc.body).toBe("");
|
|
181
|
+
expect(doc.bodyLines).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── buildPluginBrowserItems ─────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
const marketplace: Marketplace = {
|
|
188
|
+
name: "mattpocock",
|
|
189
|
+
displayName: "Matt Pocock",
|
|
190
|
+
source: { source: "github", repo: "mattpocock/skills" },
|
|
191
|
+
description: "Agent skills for real engineering",
|
|
192
|
+
featured: true,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
|
|
196
|
+
return {
|
|
197
|
+
id: "mattpocock-skills@mattpocock",
|
|
198
|
+
name: "mattpocock-skills",
|
|
199
|
+
version: "1.0.0",
|
|
200
|
+
description: "Matt Pocock's agent skills",
|
|
201
|
+
marketplace: "mattpocock",
|
|
202
|
+
marketplaceDisplay: "Matt Pocock",
|
|
203
|
+
enabled: false,
|
|
204
|
+
source: "./",
|
|
205
|
+
...overrides,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function build(
|
|
210
|
+
plugin: PluginInfo,
|
|
211
|
+
expandedPlugins: Set<string>,
|
|
212
|
+
pluginContents: Map<string, PluginContentsEntry> = new Map(),
|
|
213
|
+
) {
|
|
214
|
+
return buildPluginBrowserItems({
|
|
215
|
+
marketplaces: [marketplace],
|
|
216
|
+
plugins: [plugin],
|
|
217
|
+
collapsedMarketplaces: new Set(),
|
|
218
|
+
expandedPlugins,
|
|
219
|
+
pluginContents,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
describe("buildPluginBrowserItems component rows", () => {
|
|
224
|
+
it("marks a plugin expandable and emits no component rows while collapsed", () => {
|
|
225
|
+
const items = build(makePlugin(), new Set());
|
|
226
|
+
|
|
227
|
+
expect(items.map((i) => i.kind)).toEqual(["category", "plugin"]);
|
|
228
|
+
const row = items[1];
|
|
229
|
+
expect(row.kind === "plugin" && row.isExpandable).toBe(true);
|
|
230
|
+
expect(row.kind === "plugin" && row.isExpanded).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("emits one row per component when expanded, of every kind", () => {
|
|
234
|
+
const items = build(
|
|
235
|
+
makePlugin(),
|
|
236
|
+
new Set(["mattpocock-skills@mattpocock"]),
|
|
237
|
+
new Map([
|
|
238
|
+
[
|
|
239
|
+
"mattpocock-skills@mattpocock",
|
|
240
|
+
{
|
|
241
|
+
status: "success",
|
|
242
|
+
components: [
|
|
243
|
+
{
|
|
244
|
+
kind: "skill",
|
|
245
|
+
name: "code-review",
|
|
246
|
+
repoPath: "skills/engineering/code-review/SKILL.md",
|
|
247
|
+
group: "engineering",
|
|
248
|
+
},
|
|
249
|
+
{ kind: "command", name: "go", repoPath: "commands/go.md" },
|
|
250
|
+
{ kind: "agent", name: "critic", repoPath: "agents/critic.md" },
|
|
251
|
+
{ kind: "mcp", name: "mnemex", repoPath: ".mcp.json" },
|
|
252
|
+
],
|
|
253
|
+
} satisfies PluginContentsEntry,
|
|
254
|
+
],
|
|
255
|
+
]),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(items.map((i) => i.kind)).toEqual([
|
|
259
|
+
"category",
|
|
260
|
+
"plugin",
|
|
261
|
+
"component",
|
|
262
|
+
"component",
|
|
263
|
+
"component",
|
|
264
|
+
"component",
|
|
265
|
+
]);
|
|
266
|
+
expect(items.slice(2).map((i) => i.label)).toEqual([
|
|
267
|
+
"code-review",
|
|
268
|
+
"go",
|
|
269
|
+
"critic",
|
|
270
|
+
"mnemex",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("gives an MCP-only plugin a row instead of rendering it as empty", () => {
|
|
275
|
+
// mnemex: no skills, no commands, no agents, one server. It used to show
|
|
276
|
+
// nothing at all, which read as a plugin that ships nothing.
|
|
277
|
+
const items = build(
|
|
278
|
+
makePlugin({ id: "mnemex@magus", name: "mnemex" }),
|
|
279
|
+
new Set(["mnemex@magus"]),
|
|
280
|
+
new Map([
|
|
281
|
+
[
|
|
282
|
+
"mnemex@magus",
|
|
283
|
+
{
|
|
284
|
+
status: "success",
|
|
285
|
+
components: [
|
|
286
|
+
{
|
|
287
|
+
kind: "mcp",
|
|
288
|
+
name: "mnemex",
|
|
289
|
+
repoPath: ".mcp.json",
|
|
290
|
+
mcpCommand: "mnemex --mcp",
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
} satisfies PluginContentsEntry,
|
|
294
|
+
],
|
|
295
|
+
]),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
expect(items[2].kind).toBe("component");
|
|
299
|
+
expect(items[2].label).toBe("mnemex");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("shows a loading row before the listing arrives", () => {
|
|
303
|
+
const items = build(
|
|
304
|
+
makePlugin(),
|
|
305
|
+
new Set(["mattpocock-skills@mattpocock"]),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(items[2].kind).toBe("contents-status");
|
|
309
|
+
expect(items[2].kind === "contents-status" && items[2].status).toBe(
|
|
310
|
+
"loading",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("surfaces a failed listing instead of rendering an empty expansion", () => {
|
|
315
|
+
const items = build(
|
|
316
|
+
makePlugin(),
|
|
317
|
+
new Set(["mattpocock-skills@mattpocock"]),
|
|
318
|
+
new Map([
|
|
319
|
+
[
|
|
320
|
+
"mattpocock-skills@mattpocock",
|
|
321
|
+
{
|
|
322
|
+
status: "error",
|
|
323
|
+
error: "GitHub API rate limit exceeded",
|
|
324
|
+
} satisfies PluginContentsEntry,
|
|
325
|
+
],
|
|
326
|
+
]),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(items[2].kind === "contents-status" && items[2].status).toBe(
|
|
330
|
+
"error",
|
|
331
|
+
);
|
|
332
|
+
expect(items[2].kind === "contents-status" && items[2].error).toBe(
|
|
333
|
+
"GitHub API rate limit exceeded",
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("does not offer expansion for an orphaned plugin", () => {
|
|
338
|
+
const items = build(makePlugin({ isOrphaned: true }), new Set());
|
|
339
|
+
|
|
340
|
+
expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("does not offer expansion when the marketplace has no repo to read", () => {
|
|
344
|
+
const items = buildPluginBrowserItems({
|
|
345
|
+
marketplaces: [
|
|
346
|
+
{ ...marketplace, source: { source: "directory", repo: undefined } },
|
|
347
|
+
],
|
|
348
|
+
plugins: [makePlugin()],
|
|
349
|
+
collapsedMarketplaces: new Set(),
|
|
350
|
+
expandedPlugins: new Set(),
|
|
351
|
+
pluginContents: new Map(),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("pluginContentsRepo", () => {
|
|
359
|
+
it("prefers a plugin's own repo over its marketplace's", () => {
|
|
360
|
+
const plugin = makePlugin({ sourceRepo: "addyosmani/agent-skills" });
|
|
361
|
+
expect(pluginContentsRepo(plugin, marketplace)).toBe(
|
|
362
|
+
"addyosmani/agent-skills",
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("falls back to the marketplace repo", () => {
|
|
367
|
+
expect(pluginContentsRepo(makePlugin(), marketplace)).toBe(
|
|
368
|
+
"mattpocock/skills",
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { resetGitHubBudget } from "../services/github-budget";
|
|
13
|
+
import type {
|
|
14
|
+
LocalMarketplace,
|
|
15
|
+
LocalMarketplacePlugin,
|
|
16
|
+
} from "../services/local-marketplace";
|
|
17
|
+
import {
|
|
18
|
+
clearMarketplaceCache,
|
|
19
|
+
fetchMarketplacePlugins,
|
|
20
|
+
getMarketplaceFetchFailures,
|
|
21
|
+
resolveMarketplacePlugins,
|
|
22
|
+
} from "../services/marketplace-fetcher";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Point the marketplace-clone lookup at an EMPTY temp directory.
|
|
26
|
+
*
|
|
27
|
+
* `resolveMarketplacePlugins` now consults a git remote ref before falling back
|
|
28
|
+
* to the checked-out clone. Without this, these tests found the operator's real
|
|
29
|
+
* `magus` clone, performed a live `git fetch`, and asserted against whatever
|
|
30
|
+
* happened to be published — 17 real plugins instead of the 2 in the fixture, and
|
|
31
|
+
* three seconds per test.
|
|
32
|
+
*/
|
|
33
|
+
let prevConfigDir: string | undefined;
|
|
34
|
+
let emptyConfigDir: string;
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
|
|
38
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
39
|
+
process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
// biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
|
|
44
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
45
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
46
|
+
fs.rmSync(emptyConfigDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Regression coverage for the "deprecated everything" bug.
|
|
51
|
+
*
|
|
52
|
+
* When `fetchMarketplacePlugins` returns an empty list (network down,
|
|
53
|
+
* repo moved, GitHub blocked, etc.), the orphan-detection loop in
|
|
54
|
+
* `getAvailablePlugins` would flag every installed plugin as
|
|
55
|
+
* `isOrphaned: true` and the UI rendered them all as "deprecated".
|
|
56
|
+
*
|
|
57
|
+
* `resolveMarketplacePlugins` adds the local-marketplace cache as a
|
|
58
|
+
* fallback so installed plugins keep rendering with their real metadata
|
|
59
|
+
* even when the machine is offline.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
function buildLocalMarketplace(
|
|
63
|
+
name: string,
|
|
64
|
+
plugins: LocalMarketplacePlugin[],
|
|
65
|
+
): LocalMarketplace {
|
|
66
|
+
return { name, description: "", plugins };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
// Session-level cache could otherwise carry over a previous test's
|
|
72
|
+
// successful fetch and short-circuit the fallback path.
|
|
73
|
+
clearMarketplaceCache();
|
|
74
|
+
// The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
|
|
75
|
+
// — it reflects GitHub's state, not ours — so a 429 in one test would
|
|
76
|
+
// otherwise gate every fetch in the next for the full backoff.
|
|
77
|
+
resetGitHubBudget();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to the local cache when the remote fetch returns empty", async () => {
|
|
81
|
+
const local = new Map<string, LocalMarketplace>([
|
|
82
|
+
[
|
|
83
|
+
"magus",
|
|
84
|
+
buildLocalMarketplace("magus", [
|
|
85
|
+
{
|
|
86
|
+
name: "code-search",
|
|
87
|
+
version: "5.3.0",
|
|
88
|
+
description: "Codebase investigation tools",
|
|
89
|
+
category: "development",
|
|
90
|
+
author: { name: "Jack Rudenko" },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "dev",
|
|
94
|
+
version: "2.7.0",
|
|
95
|
+
description: "Dev workflow",
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// An invalid repo string short-circuits `fetchMarketplacePlugins` to
|
|
102
|
+
// `[]` without any network call, simulating the offline case.
|
|
103
|
+
const resolved = await resolveMarketplacePlugins(
|
|
104
|
+
"magus",
|
|
105
|
+
"not a valid repo string",
|
|
106
|
+
local,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(resolved.plugins).toHaveLength(2);
|
|
110
|
+
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
111
|
+
"code-search",
|
|
112
|
+
"dev",
|
|
113
|
+
]);
|
|
114
|
+
const codeAnalysis = resolved.plugins.find((p) => p.name === "code-search");
|
|
115
|
+
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
116
|
+
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
117
|
+
expect(codeAnalysis?.category).toBe("development");
|
|
118
|
+
expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("labels the fallback answer as local-clone, not remote", async () => {
|
|
122
|
+
// The regression this guards: the fallback plugins were returned in the
|
|
123
|
+
// same shape as a successful remote fetch, so the caller compared installed
|
|
124
|
+
// versions against a clone nothing refreshes and reported "up to date".
|
|
125
|
+
// The source must travel with the data.
|
|
126
|
+
const local = new Map<string, LocalMarketplace>([
|
|
127
|
+
[
|
|
128
|
+
"magus",
|
|
129
|
+
buildLocalMarketplace("magus", [
|
|
130
|
+
{ name: "terminal", version: "4.1.4", description: "" },
|
|
131
|
+
]),
|
|
132
|
+
],
|
|
133
|
+
]);
|
|
134
|
+
const resolved = await resolveMarketplacePlugins(
|
|
135
|
+
"magus",
|
|
136
|
+
"not a valid repo string",
|
|
137
|
+
local,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(resolved.source).toBe("local-clone");
|
|
141
|
+
expect(resolved.failure).toBeDefined();
|
|
142
|
+
expect(resolved.failure?.kind).toBe("invalid-repo");
|
|
143
|
+
expect(resolved.failure?.marketplace).toBe("magus");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns empty when both remote and cache are empty", async () => {
|
|
147
|
+
const local = new Map<string, LocalMarketplace>();
|
|
148
|
+
const resolved = await resolveMarketplacePlugins(
|
|
149
|
+
"magus",
|
|
150
|
+
"not a valid repo string",
|
|
151
|
+
local,
|
|
152
|
+
);
|
|
153
|
+
expect(resolved.plugins).toEqual([]);
|
|
154
|
+
expect(resolved.source).toBe("none");
|
|
155
|
+
expect(resolved.failure).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns empty when remote fails AND cache exists but has no plugins", async () => {
|
|
159
|
+
const local = new Map<string, LocalMarketplace>([
|
|
160
|
+
["magus", buildLocalMarketplace("magus", [])],
|
|
161
|
+
]);
|
|
162
|
+
const resolved = await resolveMarketplacePlugins(
|
|
163
|
+
"magus",
|
|
164
|
+
"not a valid repo string",
|
|
165
|
+
local,
|
|
166
|
+
);
|
|
167
|
+
expect(resolved.plugins).toEqual([]);
|
|
168
|
+
expect(resolved.source).toBe("none");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not consult the cache when the remote returns plugins", async () => {
|
|
172
|
+
// We can't easily exercise the success branch without a live network,
|
|
173
|
+
// but we can verify that a cache entry for a DIFFERENT marketplace
|
|
174
|
+
// doesn't bleed into the requested one.
|
|
175
|
+
const local = new Map<string, LocalMarketplace>([
|
|
176
|
+
[
|
|
177
|
+
"other-marketplace",
|
|
178
|
+
buildLocalMarketplace("other-marketplace", [
|
|
179
|
+
{ name: "should-not-leak", version: "0.0.1", description: "" },
|
|
180
|
+
]),
|
|
181
|
+
],
|
|
182
|
+
]);
|
|
183
|
+
const resolved = await resolveMarketplacePlugins(
|
|
184
|
+
"magus",
|
|
185
|
+
"not a valid repo string",
|
|
186
|
+
local,
|
|
187
|
+
);
|
|
188
|
+
expect(resolved.plugins).toEqual([]);
|
|
189
|
+
expect(resolved.source).toBe("none");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The defect class these cover: a failed check that renders as a passed one.
|
|
195
|
+
*
|
|
196
|
+
* `fetchMarketplacePlugins` used to return `[]` for a rate limit, a timeout, a
|
|
197
|
+
* bad repo string and a genuinely empty marketplace alike. Every caller then had
|
|
198
|
+
* to guess, and all of them guessed "fine".
|
|
199
|
+
*/
|
|
200
|
+
describe("fetchMarketplacePlugins — failures are values", () => {
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
clearMarketplaceCache();
|
|
203
|
+
resetGitHubBudget();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("classifies a rate limit distinctly from a network problem", async () => {
|
|
207
|
+
const original = globalThis.fetch;
|
|
208
|
+
globalThis.fetch = (async () =>
|
|
209
|
+
new Response("rate limited", {
|
|
210
|
+
status: 429,
|
|
211
|
+
statusText: "Too Many Requests",
|
|
212
|
+
})) as typeof globalThis.fetch;
|
|
213
|
+
try {
|
|
214
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
215
|
+
expect(result.plugins).toEqual([]);
|
|
216
|
+
expect(result.failure?.kind).toBe("rate-limited");
|
|
217
|
+
expect(result.failure?.httpStatus).toBe(429);
|
|
218
|
+
expect(result.failure?.detail).toContain("rate limit");
|
|
219
|
+
} finally {
|
|
220
|
+
globalThis.fetch = original;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("records the failure so the UI can report it without re-fetching", async () => {
|
|
225
|
+
const original = globalThis.fetch;
|
|
226
|
+
let calls = 0;
|
|
227
|
+
globalThis.fetch = (async () => {
|
|
228
|
+
calls++;
|
|
229
|
+
return new Response("nope", { status: 500, statusText: "Server Error" });
|
|
230
|
+
}) as typeof globalThis.fetch;
|
|
231
|
+
try {
|
|
232
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
233
|
+
|
|
234
|
+
const failures = getMarketplaceFetchFailures();
|
|
235
|
+
expect(failures.map((f) => f.marketplace)).toContain("magus");
|
|
236
|
+
expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe(
|
|
237
|
+
"http",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Second call must be served from the negative cache. Re-attempting on
|
|
241
|
+
// every screen mount is what cost 30-45s of "Loading..." per tab switch.
|
|
242
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
243
|
+
expect(calls).toBe(1);
|
|
244
|
+
} finally {
|
|
245
|
+
globalThis.fetch = original;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("reports no failures when the fetch succeeds", async () => {
|
|
250
|
+
const original = globalThis.fetch;
|
|
251
|
+
globalThis.fetch = (async () =>
|
|
252
|
+
Response.json({
|
|
253
|
+
metadata: { version: "9.0.3" },
|
|
254
|
+
plugins: [{ name: "terminal", version: "4.1.6" }],
|
|
255
|
+
})) as typeof globalThis.fetch;
|
|
256
|
+
try {
|
|
257
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
258
|
+
expect(result.failure).toBeUndefined();
|
|
259
|
+
expect(result.plugins).toHaveLength(1);
|
|
260
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
261
|
+
} finally {
|
|
262
|
+
globalThis.fetch = original;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("does not send a request while the rate-limit window is still open", async () => {
|
|
267
|
+
// Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
|
|
268
|
+
// Retrying inside a known window cannot succeed, and on some endpoints a
|
|
269
|
+
// rejected request extends the penalty — so the screen waits and counts down
|
|
270
|
+
// instead. `clearMarketplaceCache()` must not be a way around that.
|
|
271
|
+
const original = globalThis.fetch;
|
|
272
|
+
let calls = 0;
|
|
273
|
+
globalThis.fetch = (async () => {
|
|
274
|
+
calls++;
|
|
275
|
+
return new Response("nope", { status: 429 });
|
|
276
|
+
}) as typeof globalThis.fetch;
|
|
277
|
+
try {
|
|
278
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
279
|
+
expect(calls).toBe(1);
|
|
280
|
+
|
|
281
|
+
clearMarketplaceCache();
|
|
282
|
+
const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
283
|
+
|
|
284
|
+
expect(calls).toBe(1); // no second request went out
|
|
285
|
+
expect(second.failure?.kind).toBe("rate-limited");
|
|
286
|
+
expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
|
|
287
|
+
} finally {
|
|
288
|
+
globalThis.fetch = original;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
|
|
293
|
+
const original = globalThis.fetch;
|
|
294
|
+
globalThis.fetch = (async () =>
|
|
295
|
+
new Response("nope", { status: 429 })) as typeof globalThis.fetch;
|
|
296
|
+
try {
|
|
297
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
298
|
+
expect(getMarketplaceFetchFailures()).toHaveLength(1);
|
|
299
|
+
|
|
300
|
+
// The cooldown expiring is what unblocks the retry — this stands in for the
|
|
301
|
+
// clock reaching `retryAt`, which is when the screen refetches by itself.
|
|
302
|
+
resetGitHubBudget();
|
|
303
|
+
clearMarketplaceCache();
|
|
304
|
+
globalThis.fetch = (async () =>
|
|
305
|
+
Response.json({
|
|
306
|
+
plugins: [{ name: "terminal", version: "4.2.0" }],
|
|
307
|
+
})) as typeof globalThis.fetch;
|
|
308
|
+
|
|
309
|
+
const recovered = await fetchMarketplacePlugins(
|
|
310
|
+
"magus",
|
|
311
|
+
"MadAppGang/magus",
|
|
312
|
+
);
|
|
313
|
+
expect(recovered.failure).toBeUndefined();
|
|
314
|
+
expect(recovered.plugins).toHaveLength(1);
|
|
315
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
316
|
+
} finally {
|
|
317
|
+
globalThis.fetch = original;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|