skillrepo 2.0.0 → 3.0.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 +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/config.mjs (PR3b of #646).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
statSync,
|
|
15
|
+
chmodSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
readConfig,
|
|
22
|
+
writeConfig,
|
|
23
|
+
clearConfig,
|
|
24
|
+
CONFIG_SCHEMA_VERSION,
|
|
25
|
+
} from "../../lib/config.mjs";
|
|
26
|
+
import { globalConfigPath } from "../../lib/paths.mjs";
|
|
27
|
+
import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
|
|
28
|
+
|
|
29
|
+
let sandbox;
|
|
30
|
+
let originalHome;
|
|
31
|
+
|
|
32
|
+
function setupSandbox() {
|
|
33
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-config-mjs-"));
|
|
34
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
35
|
+
originalHome = process.env.HOME;
|
|
36
|
+
process.env.HOME = join(sandbox, "home");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function teardownSandbox() {
|
|
40
|
+
process.env.HOME = originalHome;
|
|
41
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── readConfig ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("readConfig", () => {
|
|
47
|
+
beforeEach(setupSandbox);
|
|
48
|
+
afterEach(teardownSandbox);
|
|
49
|
+
|
|
50
|
+
it("returns null when the file does not exist", () => {
|
|
51
|
+
assert.equal(readConfig(), null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads a valid config file", () => {
|
|
55
|
+
const path = globalConfigPath();
|
|
56
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
57
|
+
writeFileSync(
|
|
58
|
+
path,
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
61
|
+
apiKey: "sk_live_abc",
|
|
62
|
+
serverUrl: "https://example.com",
|
|
63
|
+
accountSlug: "alice",
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const result = readConfig();
|
|
67
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
68
|
+
assert.equal(result.serverUrl, "https://example.com");
|
|
69
|
+
assert.equal(result.accountSlug, "alice");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("accepts a legacy v2.0.0 config file with no schemaVersion", () => {
|
|
73
|
+
const path = globalConfigPath();
|
|
74
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
75
|
+
writeFileSync(
|
|
76
|
+
path,
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
apiKey: "sk_live_abc",
|
|
79
|
+
serverUrl: "https://example.com",
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const result = readConfig();
|
|
83
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
84
|
+
assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns null on corrupt JSON", () => {
|
|
88
|
+
const path = globalConfigPath();
|
|
89
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
90
|
+
writeFileSync(path, "not json {{{");
|
|
91
|
+
assert.equal(readConfig(), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null on unknown future schema version", () => {
|
|
95
|
+
const path = globalConfigPath();
|
|
96
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
97
|
+
writeFileSync(
|
|
98
|
+
path,
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
schemaVersion: 999,
|
|
101
|
+
apiKey: "sk_live_abc",
|
|
102
|
+
serverUrl: "https://example.com",
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
assert.equal(readConfig(), null);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when apiKey is missing", () => {
|
|
109
|
+
const path = globalConfigPath();
|
|
110
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
111
|
+
writeFileSync(path, JSON.stringify({ serverUrl: "https://example.com" }));
|
|
112
|
+
assert.equal(readConfig(), null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns null when serverUrl is missing", () => {
|
|
116
|
+
const path = globalConfigPath();
|
|
117
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
118
|
+
writeFileSync(path, JSON.stringify({ apiKey: "sk_live_abc" }));
|
|
119
|
+
assert.equal(readConfig(), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns null when file is an empty object", () => {
|
|
123
|
+
const path = globalConfigPath();
|
|
124
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
125
|
+
writeFileSync(path, "{}");
|
|
126
|
+
assert.equal(readConfig(), null);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns null when file is a JSON array (not object)", () => {
|
|
130
|
+
const path = globalConfigPath();
|
|
131
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
132
|
+
writeFileSync(path, "[]");
|
|
133
|
+
assert.equal(readConfig(), null);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── writeConfig ────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("writeConfig", () => {
|
|
140
|
+
beforeEach(setupSandbox);
|
|
141
|
+
afterEach(teardownSandbox);
|
|
142
|
+
|
|
143
|
+
it("creates a new config file", () => {
|
|
144
|
+
const action = writeConfig({
|
|
145
|
+
apiKey: "sk_live_abc",
|
|
146
|
+
serverUrl: "https://example.com",
|
|
147
|
+
accountSlug: "alice",
|
|
148
|
+
});
|
|
149
|
+
assert.equal(action, "created");
|
|
150
|
+
const result = readConfig();
|
|
151
|
+
assert.equal(result.apiKey, "sk_live_abc");
|
|
152
|
+
assert.equal(result.accountSlug, "alice");
|
|
153
|
+
assert.equal(result.schemaVersion, CONFIG_SCHEMA_VERSION);
|
|
154
|
+
assert.ok(result.writtenAt); // timestamp present
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("updates an existing config file", () => {
|
|
158
|
+
writeConfig({ apiKey: "sk_live_old", serverUrl: "https://old.com" });
|
|
159
|
+
const action = writeConfig({
|
|
160
|
+
apiKey: "sk_live_new",
|
|
161
|
+
serverUrl: "https://new.com",
|
|
162
|
+
});
|
|
163
|
+
assert.equal(action, "updated");
|
|
164
|
+
const result = readConfig();
|
|
165
|
+
assert.equal(result.apiKey, "sk_live_new");
|
|
166
|
+
assert.equal(result.serverUrl, "https://new.com");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("creates parent directory if missing", () => {
|
|
170
|
+
// sandbox HOME has no .claude/skillrepo/ yet
|
|
171
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
172
|
+
assert.ok(existsSync(globalConfigPath()));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves unknown fields from an existing config", () => {
|
|
176
|
+
const path = globalConfigPath();
|
|
177
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
178
|
+
writeFileSync(
|
|
179
|
+
path,
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
182
|
+
apiKey: "sk_live_old",
|
|
183
|
+
serverUrl: "https://old.com",
|
|
184
|
+
futureField: "preserve me",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
writeConfig({
|
|
188
|
+
apiKey: "sk_live_new",
|
|
189
|
+
serverUrl: "https://new.com",
|
|
190
|
+
});
|
|
191
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
192
|
+
assert.equal(raw.futureField, "preserve me");
|
|
193
|
+
assert.equal(raw.apiKey, "sk_live_new");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("sets 0600 permissions on POSIX", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
197
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
198
|
+
const path = globalConfigPath();
|
|
199
|
+
const mode = statSync(path).mode & 0o777;
|
|
200
|
+
assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("throws validationError on missing apiKey", () => {
|
|
204
|
+
assert.throws(
|
|
205
|
+
() => writeConfig({ serverUrl: "https://example.com" }),
|
|
206
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("throws validationError on missing serverUrl", () => {
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => writeConfig({ apiKey: "sk_live_abc" }),
|
|
213
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("throws validationError on null config", () => {
|
|
218
|
+
assert.throws(
|
|
219
|
+
() => writeConfig(null),
|
|
220
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("throws diskError when parent dir is read-only", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
225
|
+
// Create the grandparent dir and make the parent path non-writable
|
|
226
|
+
const grandParent = join(process.env.HOME, ".claude");
|
|
227
|
+
mkdirSync(grandParent, { recursive: true });
|
|
228
|
+
chmodSync(grandParent, 0o555);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
assert.throws(
|
|
232
|
+
() => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
|
|
233
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
234
|
+
);
|
|
235
|
+
} finally {
|
|
236
|
+
chmodSync(grandParent, 0o755);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── clearConfig ────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe("clearConfig", () => {
|
|
244
|
+
beforeEach(setupSandbox);
|
|
245
|
+
afterEach(teardownSandbox);
|
|
246
|
+
|
|
247
|
+
it("returns false when no config exists", () => {
|
|
248
|
+
assert.equal(clearConfig(), false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("removes an existing config file", () => {
|
|
252
|
+
writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" });
|
|
253
|
+
assert.ok(existsSync(globalConfigPath()));
|
|
254
|
+
assert.equal(clearConfig(), true);
|
|
255
|
+
assert.ok(!existsSync(globalConfigPath()));
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/errors.mjs (PR1 of #646).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
EXIT_OK,
|
|
10
|
+
EXIT_NETWORK,
|
|
11
|
+
EXIT_AUTH,
|
|
12
|
+
EXIT_DISK,
|
|
13
|
+
EXIT_SCOPE,
|
|
14
|
+
EXIT_VALIDATION,
|
|
15
|
+
CliError,
|
|
16
|
+
networkError,
|
|
17
|
+
authError,
|
|
18
|
+
diskError,
|
|
19
|
+
scopeError,
|
|
20
|
+
validationError,
|
|
21
|
+
isRetryable,
|
|
22
|
+
computeBackoffDelay,
|
|
23
|
+
withRetry,
|
|
24
|
+
TRANSIENT_STATUS_CODES,
|
|
25
|
+
DEFAULT_RETRY_ATTEMPTS,
|
|
26
|
+
DEFAULT_RETRY_BASE_MS,
|
|
27
|
+
DEFAULT_RETRY_CAP_MS,
|
|
28
|
+
} from "../../lib/errors.mjs";
|
|
29
|
+
|
|
30
|
+
describe("errors.mjs — exit code constants", () => {
|
|
31
|
+
it("exposes the documented exit code matrix", () => {
|
|
32
|
+
assert.equal(EXIT_OK, 0);
|
|
33
|
+
assert.equal(EXIT_NETWORK, 1);
|
|
34
|
+
assert.equal(EXIT_AUTH, 2);
|
|
35
|
+
assert.equal(EXIT_DISK, 3);
|
|
36
|
+
assert.equal(EXIT_SCOPE, 4);
|
|
37
|
+
assert.equal(EXIT_VALIDATION, 5);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("errors.mjs — CliError", () => {
|
|
42
|
+
it("carries an exit code and message", () => {
|
|
43
|
+
const err = new CliError("nope", EXIT_NETWORK);
|
|
44
|
+
assert.equal(err.message, "nope");
|
|
45
|
+
assert.equal(err.exitCode, EXIT_NETWORK);
|
|
46
|
+
assert.equal(err.name, "CliError");
|
|
47
|
+
assert.ok(err instanceof Error);
|
|
48
|
+
assert.ok(err instanceof CliError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("captures an optional cause", () => {
|
|
52
|
+
const inner = new Error("inner");
|
|
53
|
+
const err = new CliError("outer", EXIT_DISK, { cause: inner });
|
|
54
|
+
assert.equal(err.cause, inner);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("captures an optional hint", () => {
|
|
58
|
+
const err = new CliError("nope", EXIT_AUTH, { hint: "create a new key" });
|
|
59
|
+
assert.equal(err.hint, "create a new key");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not set hint or cause when omitted", () => {
|
|
63
|
+
const err = new CliError("nope", EXIT_NETWORK);
|
|
64
|
+
assert.equal(err.hint, undefined);
|
|
65
|
+
// cause defaults to undefined unless provided
|
|
66
|
+
assert.equal(err.cause, undefined);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("errors.mjs — convenience constructors", () => {
|
|
71
|
+
it("networkError exits 1", () => {
|
|
72
|
+
assert.equal(networkError("no network").exitCode, EXIT_NETWORK);
|
|
73
|
+
});
|
|
74
|
+
it("authError exits 2", () => {
|
|
75
|
+
assert.equal(authError("no auth").exitCode, EXIT_AUTH);
|
|
76
|
+
});
|
|
77
|
+
it("diskError exits 3", () => {
|
|
78
|
+
assert.equal(diskError("no disk").exitCode, EXIT_DISK);
|
|
79
|
+
});
|
|
80
|
+
it("scopeError exits 4", () => {
|
|
81
|
+
assert.equal(scopeError("no scope").exitCode, EXIT_SCOPE);
|
|
82
|
+
});
|
|
83
|
+
it("validationError exits 5", () => {
|
|
84
|
+
assert.equal(validationError("bad input").exitCode, EXIT_VALIDATION);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("convenience constructors carry hint and cause", () => {
|
|
88
|
+
const inner = new TypeError("boom");
|
|
89
|
+
const err = networkError("connection refused", { cause: inner, hint: "retry later" });
|
|
90
|
+
assert.equal(err.cause, inner);
|
|
91
|
+
assert.equal(err.hint, "retry later");
|
|
92
|
+
assert.equal(err.exitCode, EXIT_NETWORK);
|
|
93
|
+
assert.ok(err instanceof CliError);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Retry helpers (PR4 of #646, #683) ──────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("errors.mjs — TRANSIENT_STATUS_CODES", () => {
|
|
100
|
+
it("contains the four documented transient codes", () => {
|
|
101
|
+
assert.equal(TRANSIENT_STATUS_CODES.size, 4);
|
|
102
|
+
assert.ok(TRANSIENT_STATUS_CODES.has(429));
|
|
103
|
+
assert.ok(TRANSIENT_STATUS_CODES.has(502));
|
|
104
|
+
assert.ok(TRANSIENT_STATUS_CODES.has(503));
|
|
105
|
+
assert.ok(TRANSIENT_STATUS_CODES.has(504));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does NOT include 500 (internal server error is NOT transient)", () => {
|
|
109
|
+
// 500 is a catch-all that usually signals a real bug. Retrying
|
|
110
|
+
// masks issues that should surface loudly. This assertion locks
|
|
111
|
+
// the decision so nobody quietly adds 500 later.
|
|
112
|
+
assert.equal(TRANSIENT_STATUS_CODES.has(500), false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does NOT include 4xx auth/validation codes", () => {
|
|
116
|
+
for (const code of [400, 401, 403, 404, 405, 409, 422]) {
|
|
117
|
+
assert.equal(TRANSIENT_STATUS_CODES.has(code), false, `${code} must not be retryable`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("errors.mjs — isRetryable", () => {
|
|
123
|
+
it("returns true for CliError with EXIT_NETWORK", () => {
|
|
124
|
+
assert.equal(isRetryable(networkError("connect ECONNREFUSED")), true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns false for CliError with other exit codes", () => {
|
|
128
|
+
assert.equal(isRetryable(authError("bad key")), false);
|
|
129
|
+
assert.equal(isRetryable(diskError("EACCES")), false);
|
|
130
|
+
assert.equal(isRetryable(scopeError("no scope")), false);
|
|
131
|
+
assert.equal(isRetryable(validationError("bad arg")), false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns true for Response-shaped objects with transient status", () => {
|
|
135
|
+
assert.equal(isRetryable({ status: 429 }), true);
|
|
136
|
+
assert.equal(isRetryable({ status: 502 }), true);
|
|
137
|
+
assert.equal(isRetryable({ status: 503 }), true);
|
|
138
|
+
assert.equal(isRetryable({ status: 504 }), true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns false for Response-shaped objects with non-transient status", () => {
|
|
142
|
+
assert.equal(isRetryable({ status: 200 }), false);
|
|
143
|
+
assert.equal(isRetryable({ status: 401 }), false);
|
|
144
|
+
assert.equal(isRetryable({ status: 404 }), false);
|
|
145
|
+
assert.equal(isRetryable({ status: 500 }), false); // explicitly NOT retried
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns false for plain errors (TypeError, RangeError, etc.)", () => {
|
|
149
|
+
assert.equal(isRetryable(new TypeError("bad")), false);
|
|
150
|
+
assert.equal(isRetryable(new Error("generic")), false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns false for non-objects", () => {
|
|
154
|
+
assert.equal(isRetryable(null), false);
|
|
155
|
+
assert.equal(isRetryable(undefined), false);
|
|
156
|
+
assert.equal(isRetryable("string"), false);
|
|
157
|
+
assert.equal(isRetryable(42), false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("errors.mjs — computeBackoffDelay", () => {
|
|
162
|
+
it("attempt 1 always returns 0 (no sleep before first call)", () => {
|
|
163
|
+
assert.equal(computeBackoffDelay(1), 0);
|
|
164
|
+
assert.equal(computeBackoffDelay(0), 0); // defensive lower bound
|
|
165
|
+
assert.equal(computeBackoffDelay(-5), 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("attempt 2 samples in [0, baseMs)", () => {
|
|
169
|
+
// Pin randomFn to 0.999... to see the worst-case upper bound
|
|
170
|
+
const maxAt2 = computeBackoffDelay(2, { baseMs: 500, randomFn: () => 0.999999 });
|
|
171
|
+
assert.ok(maxAt2 < 500);
|
|
172
|
+
assert.ok(maxAt2 >= 499);
|
|
173
|
+
// Pin randomFn to 0 for the best case
|
|
174
|
+
assert.equal(computeBackoffDelay(2, { baseMs: 500, randomFn: () => 0 }), 0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("attempt N grows exponentially until capMs", () => {
|
|
178
|
+
// Use 0.999999 to approximate "near max" — randomFn returns
|
|
179
|
+
// in [0, 1) so the sampled delay is always strictly less
|
|
180
|
+
// than its window upper bound.
|
|
181
|
+
const near = (attempt) =>
|
|
182
|
+
computeBackoffDelay(attempt, { baseMs: 500, capMs: 8000, randomFn: () => 0.999999 });
|
|
183
|
+
// Windows (baseMs=500, formula: base * 2^(attempt-2)):
|
|
184
|
+
// attempt 2 = min(8000, 500) = 500
|
|
185
|
+
// attempt 3 = min(8000, 1000) = 1000
|
|
186
|
+
// attempt 4 = min(8000, 2000) = 2000
|
|
187
|
+
// attempt 5 = min(8000, 4000) = 4000
|
|
188
|
+
// attempt 6 = min(8000, 8000) = 8000
|
|
189
|
+
// attempt 7 = min(8000, 16000) = 8000 (capped)
|
|
190
|
+
assert.ok(near(2) < 500);
|
|
191
|
+
assert.ok(near(3) < 1000);
|
|
192
|
+
assert.ok(near(4) < 2000);
|
|
193
|
+
assert.ok(near(5) < 4000);
|
|
194
|
+
assert.ok(near(6) < 8000);
|
|
195
|
+
assert.ok(near(7) < 8000, "cap should hold at high attempt numbers");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("uses DEFAULT_RETRY_BASE_MS and DEFAULT_RETRY_CAP_MS when unspecified", () => {
|
|
199
|
+
assert.equal(DEFAULT_RETRY_BASE_MS, 500);
|
|
200
|
+
assert.equal(DEFAULT_RETRY_CAP_MS, 8000);
|
|
201
|
+
assert.equal(DEFAULT_RETRY_ATTEMPTS, 3);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("errors.mjs — withRetry", () => {
|
|
206
|
+
function makeSleepTracker() {
|
|
207
|
+
const calls = [];
|
|
208
|
+
const fn = async (ms) => {
|
|
209
|
+
calls.push(ms);
|
|
210
|
+
};
|
|
211
|
+
fn.calls = calls;
|
|
212
|
+
return fn;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("returns the result immediately on first success", async () => {
|
|
216
|
+
const sleepFn = makeSleepTracker();
|
|
217
|
+
const result = await withRetry(async () => "ok", { sleepFn });
|
|
218
|
+
assert.equal(result, "ok");
|
|
219
|
+
assert.equal(sleepFn.calls.length, 0, "no sleep on first success");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("retries a thrown networkError and eventually succeeds", async () => {
|
|
223
|
+
let attempts = 0;
|
|
224
|
+
const sleepFn = makeSleepTracker();
|
|
225
|
+
const result = await withRetry(
|
|
226
|
+
async () => {
|
|
227
|
+
attempts++;
|
|
228
|
+
if (attempts < 3) throw networkError("ECONNRESET");
|
|
229
|
+
return "eventually ok";
|
|
230
|
+
},
|
|
231
|
+
{ sleepFn, randomFn: () => 0 }, // zero-jitter for determinism
|
|
232
|
+
);
|
|
233
|
+
assert.equal(result, "eventually ok");
|
|
234
|
+
assert.equal(attempts, 3);
|
|
235
|
+
assert.equal(sleepFn.calls.length, 2, "slept between each retry");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("retries a transient Response and eventually succeeds", async () => {
|
|
239
|
+
let attempts = 0;
|
|
240
|
+
const sleepFn = makeSleepTracker();
|
|
241
|
+
const result = await withRetry(
|
|
242
|
+
async () => {
|
|
243
|
+
attempts++;
|
|
244
|
+
if (attempts < 2) return { status: 503 };
|
|
245
|
+
return { status: 200, ok: true };
|
|
246
|
+
},
|
|
247
|
+
{ sleepFn, randomFn: () => 0 },
|
|
248
|
+
);
|
|
249
|
+
assert.equal(result.status, 200);
|
|
250
|
+
assert.equal(attempts, 2);
|
|
251
|
+
assert.equal(sleepFn.calls.length, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("rethrows the last networkError after exhausting attempts", async () => {
|
|
255
|
+
let attempts = 0;
|
|
256
|
+
const sleepFn = makeSleepTracker();
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
() =>
|
|
259
|
+
withRetry(
|
|
260
|
+
async () => {
|
|
261
|
+
attempts++;
|
|
262
|
+
throw networkError(`attempt ${attempts}`);
|
|
263
|
+
},
|
|
264
|
+
{ attempts: 3, sleepFn, randomFn: () => 0 },
|
|
265
|
+
),
|
|
266
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK && err.message === "attempt 3",
|
|
267
|
+
);
|
|
268
|
+
assert.equal(attempts, 3);
|
|
269
|
+
// Slept between attempt 1→2 and attempt 2→3. No sleep after the
|
|
270
|
+
// final failing attempt — the retry loop throws immediately.
|
|
271
|
+
assert.equal(sleepFn.calls.length, 2);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns the final transient Response after exhausting attempts", async () => {
|
|
275
|
+
let attempts = 0;
|
|
276
|
+
const sleepFn = makeSleepTracker();
|
|
277
|
+
// Retry exhausted: the caller will map the final 503 via
|
|
278
|
+
// mapErrorResponse — withRetry does NOT convert the Response
|
|
279
|
+
// into an error itself.
|
|
280
|
+
const result = await withRetry(
|
|
281
|
+
async () => {
|
|
282
|
+
attempts++;
|
|
283
|
+
return { status: 503 };
|
|
284
|
+
},
|
|
285
|
+
{ attempts: 2, sleepFn, randomFn: () => 0 },
|
|
286
|
+
);
|
|
287
|
+
assert.equal(result.status, 503);
|
|
288
|
+
assert.equal(attempts, 2);
|
|
289
|
+
assert.equal(sleepFn.calls.length, 1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("does NOT retry non-transient CliErrors (auth, disk, scope, validation)", async () => {
|
|
293
|
+
const sleepFn = makeSleepTracker();
|
|
294
|
+
let attempts = 0;
|
|
295
|
+
await assert.rejects(
|
|
296
|
+
() =>
|
|
297
|
+
withRetry(
|
|
298
|
+
async () => {
|
|
299
|
+
attempts++;
|
|
300
|
+
throw authError("bad key");
|
|
301
|
+
},
|
|
302
|
+
{ sleepFn, randomFn: () => 0 },
|
|
303
|
+
),
|
|
304
|
+
(err) => err.exitCode === EXIT_AUTH,
|
|
305
|
+
);
|
|
306
|
+
assert.equal(attempts, 1, "auth error must not retry");
|
|
307
|
+
assert.equal(sleepFn.calls.length, 0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("does NOT retry plain errors (TypeError, bugs in callee)", async () => {
|
|
311
|
+
const sleepFn = makeSleepTracker();
|
|
312
|
+
let attempts = 0;
|
|
313
|
+
await assert.rejects(
|
|
314
|
+
() =>
|
|
315
|
+
withRetry(async () => {
|
|
316
|
+
attempts++;
|
|
317
|
+
throw new TypeError("programmer error");
|
|
318
|
+
}, { sleepFn }),
|
|
319
|
+
(err) => err instanceof TypeError,
|
|
320
|
+
);
|
|
321
|
+
assert.equal(attempts, 1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("fires onRetry callback before each retry with attempt + delay", async () => {
|
|
325
|
+
const calls = [];
|
|
326
|
+
let attempts = 0;
|
|
327
|
+
await withRetry(
|
|
328
|
+
async () => {
|
|
329
|
+
attempts++;
|
|
330
|
+
if (attempts < 3) throw networkError("fail");
|
|
331
|
+
return "ok";
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
sleepFn: async () => {},
|
|
335
|
+
randomFn: () => 0.5,
|
|
336
|
+
onRetry: (info) => calls.push(info),
|
|
337
|
+
},
|
|
338
|
+
);
|
|
339
|
+
assert.equal(calls.length, 2);
|
|
340
|
+
assert.equal(calls[0].attempt, 1);
|
|
341
|
+
assert.equal(calls[1].attempt, 2);
|
|
342
|
+
assert.ok(calls[0].cause instanceof CliError);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("rejects non-positive attempts count", async () => {
|
|
346
|
+
await assert.rejects(
|
|
347
|
+
() => withRetry(async () => "ok", { attempts: 0 }),
|
|
348
|
+
(err) => err.exitCode === EXIT_VALIDATION,
|
|
349
|
+
);
|
|
350
|
+
await assert.rejects(
|
|
351
|
+
() => withRetry(async () => "ok", { attempts: -1 }),
|
|
352
|
+
(err) => err.exitCode === EXIT_VALIDATION,
|
|
353
|
+
);
|
|
354
|
+
await assert.rejects(
|
|
355
|
+
() => withRetry(async () => "ok", { attempts: 1.5 }),
|
|
356
|
+
(err) => err.exitCode === EXIT_VALIDATION,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
});
|