claudeup 4.36.0 → 4.38.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/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -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 +722 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +312 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +124 -3
- package/src/ui/state/types.ts +76 -3
- package/src/utils/config-dir.ts +47 -0
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
activeCooldowns,
|
|
4
|
+
cooldownFor,
|
|
5
|
+
describeCooldown,
|
|
6
|
+
formatWait,
|
|
7
|
+
hostOf,
|
|
8
|
+
recordRateLimit,
|
|
9
|
+
recordSuccess,
|
|
10
|
+
remainingFromHeaders,
|
|
11
|
+
resetGitHubBudget,
|
|
12
|
+
waitMs,
|
|
13
|
+
} from "../services/github-budget";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The design hinges on one measured asymmetry (2026-08-17):
|
|
17
|
+
*
|
|
18
|
+
* api.github.com 429/403 carries x-ratelimit-reset and retry-after
|
|
19
|
+
* raw.githubusercontent.com 429 carries NEITHER — only a request id and a date
|
|
20
|
+
*
|
|
21
|
+
* So the module must be able to say "resets in 4m" when GitHub said so, and must
|
|
22
|
+
* NOT say it otherwise. Fabricating a reset time would be the same sin as the
|
|
23
|
+
* original bug: reporting a guess as a fact.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const NOW = 1_786_980_000_000;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
resetGitHubBudget();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("hostOf", () => {
|
|
33
|
+
it("recognises the two budgeted hosts and nothing else", () => {
|
|
34
|
+
expect(hostOf("https://api.github.com/repos/x/y")).toBe("api.github.com");
|
|
35
|
+
expect(hostOf("https://raw.githubusercontent.com/x/y/main/f.json")).toBe(
|
|
36
|
+
"raw.githubusercontent.com",
|
|
37
|
+
);
|
|
38
|
+
expect(hostOf("https://github.com/x/y")).toBeNull();
|
|
39
|
+
expect(hostOf("not a url")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("recordRateLimit — exact timing when GitHub provides it", () => {
|
|
44
|
+
it("honours retry-after in seconds", () => {
|
|
45
|
+
const cooldown = recordRateLimit(
|
|
46
|
+
"api.github.com",
|
|
47
|
+
new Headers({ "retry-after": "120" }),
|
|
48
|
+
NOW,
|
|
49
|
+
);
|
|
50
|
+
expect(cooldown.exact).toBe(true);
|
|
51
|
+
expect(cooldown.until).toBe(NOW + 120_000);
|
|
52
|
+
expect(describeCooldown(cooldown, NOW)).toBe(
|
|
53
|
+
"GitHub rate limit resets in 2m",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("honours retry-after as an HTTP date", () => {
|
|
58
|
+
const when = new Date(NOW + 300_000).toUTCString();
|
|
59
|
+
const cooldown = recordRateLimit(
|
|
60
|
+
"api.github.com",
|
|
61
|
+
new Headers({ "retry-after": when }),
|
|
62
|
+
NOW,
|
|
63
|
+
);
|
|
64
|
+
expect(cooldown.exact).toBe(true);
|
|
65
|
+
// UTCString drops sub-second precision, so allow a second of slack.
|
|
66
|
+
expect(Math.abs(cooldown.until - (NOW + 300_000))).toBeLessThanOrEqual(1000);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads x-ratelimit-reset as epoch SECONDS, not milliseconds", () => {
|
|
70
|
+
// Treating it as ms lands in 1970, so the cooldown expires instantly and the
|
|
71
|
+
// UI reports "not rate limited" while GitHub is still refusing every call.
|
|
72
|
+
const resetEpochSeconds = Math.floor(NOW / 1000) + 600;
|
|
73
|
+
const cooldown = recordRateLimit(
|
|
74
|
+
"api.github.com",
|
|
75
|
+
new Headers({ "x-ratelimit-reset": String(resetEpochSeconds) }),
|
|
76
|
+
NOW,
|
|
77
|
+
);
|
|
78
|
+
expect(cooldown.exact).toBe(true);
|
|
79
|
+
expect(cooldown.until).toBe(resetEpochSeconds * 1000);
|
|
80
|
+
expect(waitMs("api.github.com", NOW)).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("caps an implausible server window so a bad header cannot wedge the UI", () => {
|
|
84
|
+
const cooldown = recordRateLimit(
|
|
85
|
+
"api.github.com",
|
|
86
|
+
new Headers({ "retry-after": String(60 * 60 * 24 * 30) }),
|
|
87
|
+
NOW,
|
|
88
|
+
);
|
|
89
|
+
expect(cooldown.until - NOW).toBeLessThanOrEqual(60 * 60_000);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("recordRateLimit — backoff when GitHub provides nothing", () => {
|
|
94
|
+
it("does not claim a reset time it was never told", () => {
|
|
95
|
+
// This is the raw.githubusercontent.com case, measured: a bare 429.
|
|
96
|
+
const cooldown = recordRateLimit(
|
|
97
|
+
"raw.githubusercontent.com",
|
|
98
|
+
new Headers(),
|
|
99
|
+
NOW,
|
|
100
|
+
);
|
|
101
|
+
expect(cooldown.exact).toBe(false);
|
|
102
|
+
expect(describeCooldown(cooldown, NOW)).toContain("next attempt in");
|
|
103
|
+
expect(describeCooldown(cooldown, NOW)).not.toContain("resets in");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("backs off further on each consecutive failure", () => {
|
|
107
|
+
const first = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
108
|
+
const second = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
109
|
+
const third = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
110
|
+
expect(second.until).toBeGreaterThan(first.until);
|
|
111
|
+
expect(third.until).toBeGreaterThan(second.until);
|
|
112
|
+
expect(third.strikes).toBe(3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ignores a retry-after that is garbage or already past", () => {
|
|
116
|
+
const garbage = recordRateLimit(
|
|
117
|
+
"raw.githubusercontent.com",
|
|
118
|
+
new Headers({ "retry-after": "soon-ish" }),
|
|
119
|
+
NOW,
|
|
120
|
+
);
|
|
121
|
+
expect(garbage.exact).toBe(false);
|
|
122
|
+
expect(garbage.until).toBeGreaterThan(NOW);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("the gate", () => {
|
|
127
|
+
it("blocks while cooling and clears when the window passes", () => {
|
|
128
|
+
const cooldown = recordRateLimit(
|
|
129
|
+
"raw.githubusercontent.com",
|
|
130
|
+
undefined,
|
|
131
|
+
NOW,
|
|
132
|
+
);
|
|
133
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).not.toBeNull();
|
|
134
|
+
expect(
|
|
135
|
+
cooldownFor("raw.githubusercontent.com", cooldown.until + 1),
|
|
136
|
+
).toBeNull();
|
|
137
|
+
expect(waitMs("raw.githubusercontent.com", cooldown.until + 1)).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the two hosts on separate budgets", () => {
|
|
141
|
+
// They are separate buckets upstream. Blocking raw because the API is
|
|
142
|
+
// exhausted would stop work that would have succeeded.
|
|
143
|
+
recordRateLimit("api.github.com", new Headers({ "retry-after": "60" }), NOW);
|
|
144
|
+
expect(cooldownFor("api.github.com", NOW)).not.toBeNull();
|
|
145
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("clears the cooldown and the strike count on success", () => {
|
|
149
|
+
// Without resetting strikes, one bad afternoon pins the backoff at ten
|
|
150
|
+
// minutes for the rest of the session even though calls are succeeding.
|
|
151
|
+
recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
152
|
+
recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
153
|
+
recordSuccess("raw.githubusercontent.com");
|
|
154
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).toBeNull();
|
|
155
|
+
|
|
156
|
+
const afterRecovery = recordRateLimit(
|
|
157
|
+
"raw.githubusercontent.com",
|
|
158
|
+
undefined,
|
|
159
|
+
NOW,
|
|
160
|
+
);
|
|
161
|
+
expect(afterRecovery.strikes).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("lists active cooldowns soonest first", () => {
|
|
165
|
+
recordRateLimit(
|
|
166
|
+
"api.github.com",
|
|
167
|
+
new Headers({ "retry-after": "600" }),
|
|
168
|
+
NOW,
|
|
169
|
+
);
|
|
170
|
+
recordRateLimit(
|
|
171
|
+
"raw.githubusercontent.com",
|
|
172
|
+
new Headers({ "retry-after": "30" }),
|
|
173
|
+
NOW,
|
|
174
|
+
);
|
|
175
|
+
const active = activeCooldowns(NOW);
|
|
176
|
+
expect(active.map((c) => c.host)).toEqual([
|
|
177
|
+
"raw.githubusercontent.com",
|
|
178
|
+
"api.github.com",
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("remainingFromHeaders", () => {
|
|
184
|
+
it("reads the budget so a caller can stop before tripping the limit", () => {
|
|
185
|
+
expect(
|
|
186
|
+
remainingFromHeaders(new Headers({ "x-ratelimit-remaining": "51" })),
|
|
187
|
+
).toBe(51);
|
|
188
|
+
expect(remainingFromHeaders(new Headers())).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("formatWait", () => {
|
|
193
|
+
it("reads naturally at every scale", () => {
|
|
194
|
+
expect(formatWait(0)).toBe("0s");
|
|
195
|
+
expect(formatWait(45_000)).toBe("45s");
|
|
196
|
+
expect(formatWait(90_000)).toBe("2m");
|
|
197
|
+
expect(formatWait(3_600_000)).toBe("1h 0m");
|
|
198
|
+
expect(formatWait(4_320_000)).toBe("1h 12m");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { openInDefaultApp, openerFor } from "../utils/open-file.js";
|
|
3
|
+
|
|
4
|
+
describe("openerFor", () => {
|
|
5
|
+
test("uses the macOS opener on darwin", () => {
|
|
6
|
+
expect(openerFor("darwin")).toEqual({ command: "open", args: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("uses xdg-open on linux", () => {
|
|
10
|
+
// claudeup ships a linux-x64 binary, and the pre-existing `open "${url}"`
|
|
11
|
+
// call elsewhere in the UI silently does nothing there.
|
|
12
|
+
expect(openerFor("linux")).toEqual({ command: "xdg-open", args: [] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("passes an empty window title to cmd start on windows", () => {
|
|
16
|
+
// `start` treats its first quoted argument as the window title, so
|
|
17
|
+
// without the empty string a quoted path is consumed as one.
|
|
18
|
+
expect(openerFor("win32")).toEqual({
|
|
19
|
+
command: "cmd",
|
|
20
|
+
args: ["/c", "start", ""],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("falls back to xdg-open on anything else", () => {
|
|
25
|
+
expect(openerFor("freebsd").command).toBe("xdg-open");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("openInDefaultApp", () => {
|
|
30
|
+
const missingOpener = {
|
|
31
|
+
command: "claudeup-test-opener-that-cannot-exist",
|
|
32
|
+
args: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test("reports a missing opener rather than failing silently", async () => {
|
|
36
|
+
// An injected command that cannot exist, NOT a real platform's opener.
|
|
37
|
+
// The first version assumed `xdg-open` was absent — true on a Mac dev
|
|
38
|
+
// box, false on the Ubuntu CI runner, where it both failed the
|
|
39
|
+
// assertion and spawned a real opener on the build machine.
|
|
40
|
+
await expect(
|
|
41
|
+
openInDefaultApp("/tmp/whatever.md", "linux", missingOpener),
|
|
42
|
+
).rejects.toThrow(/not on PATH/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("settles promptly instead of hanging", async () => {
|
|
46
|
+
// The promise must settle when the child is spawned, not when the viewer
|
|
47
|
+
// is closed — waiting for exit would freeze the TUI for as long as the
|
|
48
|
+
// user's editor stayed open. Asserted on the error path so the suite
|
|
49
|
+
// never launches a real application on the developer's desktop.
|
|
50
|
+
const settled = await Promise.race([
|
|
51
|
+
openInDefaultApp("/tmp/whatever.md", "linux", missingOpener).then(
|
|
52
|
+
() => "resolved",
|
|
53
|
+
() => "rejected",
|
|
54
|
+
),
|
|
55
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), 2000)),
|
|
56
|
+
]);
|
|
57
|
+
expect(settled).not.toBe("timeout");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,13 +1,43 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll, beforeAll } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
|
-
resolveMarketplacePlugins,
|
|
4
6
|
clearMarketplaceCache,
|
|
7
|
+
fetchMarketplacePlugins,
|
|
8
|
+
getMarketplaceFetchFailures,
|
|
9
|
+
resolveMarketplacePlugins,
|
|
5
10
|
} from "../services/marketplace-fetcher";
|
|
11
|
+
import { resetGitHubBudget } from "../services/github-budget";
|
|
6
12
|
import type {
|
|
7
13
|
LocalMarketplace,
|
|
8
14
|
LocalMarketplacePlugin,
|
|
9
15
|
} from "../services/local-marketplace";
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Point the marketplace-clone lookup at an EMPTY temp directory.
|
|
19
|
+
*
|
|
20
|
+
* `resolveMarketplacePlugins` now consults a git remote ref before falling back
|
|
21
|
+
* to the checked-out clone. Without this, these tests found the operator's real
|
|
22
|
+
* `magus` clone, performed a live `git fetch`, and asserted against whatever
|
|
23
|
+
* happened to be published — 17 real plugins instead of the 2 in the fixture, and
|
|
24
|
+
* three seconds per test.
|
|
25
|
+
*/
|
|
26
|
+
let prevConfigDir: string | undefined;
|
|
27
|
+
let emptyConfigDir: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
|
|
31
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
32
|
+
process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
37
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
38
|
+
fs.rmSync(emptyConfigDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* Regression coverage for the "deprecated everything" bug.
|
|
13
43
|
*
|
|
@@ -33,6 +63,10 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
33
63
|
// Session-level cache could otherwise carry over a previous test's
|
|
34
64
|
// successful fetch and short-circuit the fallback path.
|
|
35
65
|
clearMarketplaceCache();
|
|
66
|
+
// The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
|
|
67
|
+
// — it reflects GitHub's state, not ours — so a 429 in one test would
|
|
68
|
+
// otherwise gate every fetch in the next for the full backoff.
|
|
69
|
+
resetGitHubBudget();
|
|
36
70
|
});
|
|
37
71
|
|
|
38
72
|
it("falls back to the local cache when the remote fetch returns empty", async () => {
|
|
@@ -64,18 +98,45 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
64
98
|
local,
|
|
65
99
|
);
|
|
66
100
|
|
|
67
|
-
expect(resolved).toHaveLength(2);
|
|
68
|
-
expect(resolved.map((p) => p.name).sort()).toEqual([
|
|
101
|
+
expect(resolved.plugins).toHaveLength(2);
|
|
102
|
+
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
69
103
|
"code-analysis",
|
|
70
104
|
"dev",
|
|
71
105
|
]);
|
|
72
|
-
const codeAnalysis = resolved.find(
|
|
106
|
+
const codeAnalysis = resolved.plugins.find(
|
|
107
|
+
(p) => p.name === "code-analysis",
|
|
108
|
+
);
|
|
73
109
|
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
74
110
|
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
75
111
|
expect(codeAnalysis?.category).toBe("development");
|
|
76
112
|
expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
|
|
77
113
|
});
|
|
78
114
|
|
|
115
|
+
it("labels the fallback answer as local-clone, not remote", async () => {
|
|
116
|
+
// The regression this guards: the fallback plugins were returned in the
|
|
117
|
+
// same shape as a successful remote fetch, so the caller compared installed
|
|
118
|
+
// versions against a clone nothing refreshes and reported "up to date".
|
|
119
|
+
// The source must travel with the data.
|
|
120
|
+
const local = new Map<string, LocalMarketplace>([
|
|
121
|
+
[
|
|
122
|
+
"magus",
|
|
123
|
+
buildLocalMarketplace("magus", [
|
|
124
|
+
{ name: "terminal", version: "4.1.4", description: "" },
|
|
125
|
+
]),
|
|
126
|
+
],
|
|
127
|
+
]);
|
|
128
|
+
const resolved = await resolveMarketplacePlugins(
|
|
129
|
+
"magus",
|
|
130
|
+
"not a valid repo string",
|
|
131
|
+
local,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(resolved.source).toBe("local-clone");
|
|
135
|
+
expect(resolved.failure).toBeDefined();
|
|
136
|
+
expect(resolved.failure?.kind).toBe("invalid-repo");
|
|
137
|
+
expect(resolved.failure?.marketplace).toBe("magus");
|
|
138
|
+
});
|
|
139
|
+
|
|
79
140
|
it("returns empty when both remote and cache are empty", async () => {
|
|
80
141
|
const local = new Map<string, LocalMarketplace>();
|
|
81
142
|
const resolved = await resolveMarketplacePlugins(
|
|
@@ -83,7 +144,9 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
83
144
|
"not a valid repo string",
|
|
84
145
|
local,
|
|
85
146
|
);
|
|
86
|
-
expect(resolved).toEqual([]);
|
|
147
|
+
expect(resolved.plugins).toEqual([]);
|
|
148
|
+
expect(resolved.source).toBe("none");
|
|
149
|
+
expect(resolved.failure).toBeDefined();
|
|
87
150
|
});
|
|
88
151
|
|
|
89
152
|
it("returns empty when remote fails AND cache exists but has no plugins", async () => {
|
|
@@ -95,7 +158,8 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
95
158
|
"not a valid repo string",
|
|
96
159
|
local,
|
|
97
160
|
);
|
|
98
|
-
expect(resolved).toEqual([]);
|
|
161
|
+
expect(resolved.plugins).toEqual([]);
|
|
162
|
+
expect(resolved.source).toBe("none");
|
|
99
163
|
});
|
|
100
164
|
|
|
101
165
|
it("does not consult the cache when the remote returns plugins", async () => {
|
|
@@ -115,6 +179,134 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
115
179
|
"not a valid repo string",
|
|
116
180
|
local,
|
|
117
181
|
);
|
|
118
|
-
expect(resolved).toEqual([]);
|
|
182
|
+
expect(resolved.plugins).toEqual([]);
|
|
183
|
+
expect(resolved.source).toBe("none");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The defect class these cover: a failed check that renders as a passed one.
|
|
189
|
+
*
|
|
190
|
+
* `fetchMarketplacePlugins` used to return `[]` for a rate limit, a timeout, a
|
|
191
|
+
* bad repo string and a genuinely empty marketplace alike. Every caller then had
|
|
192
|
+
* to guess, and all of them guessed "fine".
|
|
193
|
+
*/
|
|
194
|
+
describe("fetchMarketplacePlugins — failures are values", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
clearMarketplaceCache();
|
|
197
|
+
resetGitHubBudget();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("classifies a rate limit distinctly from a network problem", async () => {
|
|
201
|
+
const original = globalThis.fetch;
|
|
202
|
+
globalThis.fetch = (async () =>
|
|
203
|
+
new Response("rate limited", {
|
|
204
|
+
status: 429,
|
|
205
|
+
statusText: "Too Many Requests",
|
|
206
|
+
})) as typeof globalThis.fetch;
|
|
207
|
+
try {
|
|
208
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
209
|
+
expect(result.plugins).toEqual([]);
|
|
210
|
+
expect(result.failure?.kind).toBe("rate-limited");
|
|
211
|
+
expect(result.failure?.httpStatus).toBe(429);
|
|
212
|
+
expect(result.failure?.detail).toContain("rate limit");
|
|
213
|
+
} finally {
|
|
214
|
+
globalThis.fetch = original;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("records the failure so the UI can report it without re-fetching", async () => {
|
|
219
|
+
const original = globalThis.fetch;
|
|
220
|
+
let calls = 0;
|
|
221
|
+
globalThis.fetch = (async () => {
|
|
222
|
+
calls++;
|
|
223
|
+
return new Response("nope", { status: 500, statusText: "Server Error" });
|
|
224
|
+
}) as typeof globalThis.fetch;
|
|
225
|
+
try {
|
|
226
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
227
|
+
|
|
228
|
+
const failures = getMarketplaceFetchFailures();
|
|
229
|
+
expect(failures.map((f) => f.marketplace)).toContain("magus");
|
|
230
|
+
expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe("http");
|
|
231
|
+
|
|
232
|
+
// Second call must be served from the negative cache. Re-attempting on
|
|
233
|
+
// every screen mount is what cost 30-45s of "Loading..." per tab switch.
|
|
234
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
235
|
+
expect(calls).toBe(1);
|
|
236
|
+
} finally {
|
|
237
|
+
globalThis.fetch = original;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports no failures when the fetch succeeds", async () => {
|
|
242
|
+
const original = globalThis.fetch;
|
|
243
|
+
globalThis.fetch = (async () =>
|
|
244
|
+
Response.json({
|
|
245
|
+
metadata: { version: "9.0.3" },
|
|
246
|
+
plugins: [{ name: "terminal", version: "4.1.6" }],
|
|
247
|
+
})) as typeof globalThis.fetch;
|
|
248
|
+
try {
|
|
249
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
250
|
+
expect(result.failure).toBeUndefined();
|
|
251
|
+
expect(result.plugins).toHaveLength(1);
|
|
252
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
253
|
+
} finally {
|
|
254
|
+
globalThis.fetch = original;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not send a request while the rate-limit window is still open", async () => {
|
|
259
|
+
// Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
|
|
260
|
+
// Retrying inside a known window cannot succeed, and on some endpoints a
|
|
261
|
+
// rejected request extends the penalty — so the screen waits and counts down
|
|
262
|
+
// instead. `clearMarketplaceCache()` must not be a way around that.
|
|
263
|
+
const original = globalThis.fetch;
|
|
264
|
+
let calls = 0;
|
|
265
|
+
globalThis.fetch = (async () => {
|
|
266
|
+
calls++;
|
|
267
|
+
return new Response("nope", { status: 429 });
|
|
268
|
+
}) as typeof globalThis.fetch;
|
|
269
|
+
try {
|
|
270
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
271
|
+
expect(calls).toBe(1);
|
|
272
|
+
|
|
273
|
+
clearMarketplaceCache();
|
|
274
|
+
const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
275
|
+
|
|
276
|
+
expect(calls).toBe(1); // no second request went out
|
|
277
|
+
expect(second.failure?.kind).toBe("rate-limited");
|
|
278
|
+
expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
|
|
279
|
+
} finally {
|
|
280
|
+
globalThis.fetch = original;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
|
|
285
|
+
const original = globalThis.fetch;
|
|
286
|
+
globalThis.fetch = (async () =>
|
|
287
|
+
new Response("nope", { status: 429 })) as typeof globalThis.fetch;
|
|
288
|
+
try {
|
|
289
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
290
|
+
expect(getMarketplaceFetchFailures()).toHaveLength(1);
|
|
291
|
+
|
|
292
|
+
// The cooldown expiring is what unblocks the retry — this stands in for the
|
|
293
|
+
// clock reaching `retryAt`, which is when the screen refetches by itself.
|
|
294
|
+
resetGitHubBudget();
|
|
295
|
+
clearMarketplaceCache();
|
|
296
|
+
globalThis.fetch = (async () =>
|
|
297
|
+
Response.json({
|
|
298
|
+
plugins: [{ name: "terminal", version: "4.2.0" }],
|
|
299
|
+
})) as typeof globalThis.fetch;
|
|
300
|
+
|
|
301
|
+
const recovered = await fetchMarketplacePlugins(
|
|
302
|
+
"magus",
|
|
303
|
+
"MadAppGang/magus",
|
|
304
|
+
);
|
|
305
|
+
expect(recovered.failure).toBeUndefined();
|
|
306
|
+
expect(recovered.plugins).toHaveLength(1);
|
|
307
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
308
|
+
} finally {
|
|
309
|
+
globalThis.fetch = original;
|
|
310
|
+
}
|
|
119
311
|
});
|
|
120
312
|
});
|