skillrepo 4.8.0 → 4.8.2
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/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
|
@@ -4,13 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
chmodSync,
|
|
14
|
+
} from "node:fs";
|
|
8
15
|
import { join } from "node:path";
|
|
9
16
|
import { tmpdir } from "node:os";
|
|
10
17
|
|
|
11
18
|
import { runRemove } from "../../commands/remove.mjs";
|
|
12
19
|
import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
13
|
-
import {
|
|
20
|
+
import { writeLastSync, readLastSync } from "../../lib/sync.mjs";
|
|
21
|
+
import {
|
|
22
|
+
CliError,
|
|
23
|
+
EXIT_VALIDATION,
|
|
24
|
+
EXIT_AUTH,
|
|
25
|
+
EXIT_SCOPE,
|
|
26
|
+
EXIT_NETWORK,
|
|
27
|
+
} from "../../lib/errors.mjs";
|
|
14
28
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
15
29
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
16
30
|
import {
|
|
@@ -241,3 +255,208 @@ describe("runRemove — error paths", () => {
|
|
|
241
255
|
);
|
|
242
256
|
});
|
|
243
257
|
});
|
|
258
|
+
|
|
259
|
+
// ── Coverage gap closure: --agent vectors + transport errors ───────────
|
|
260
|
+
//
|
|
261
|
+
// `remove` shares `requireVendorTargets` with add/get and the same
|
|
262
|
+
// http.mjs error mapping. These lock the vectors that were untested at
|
|
263
|
+
// the command layer: the `--agent none` no-op rejection (exit 5), the
|
|
264
|
+
// vendor-scoped local delete (only the cursor `.agents/skills/` placement
|
|
265
|
+
// is removed, the claude placement survives), and the network-failure
|
|
266
|
+
// exit 1. `removeSkillFromLibrary` is a single-shot (no retry) DELETE, so
|
|
267
|
+
// a connection refusal surfaces immediately as networkError.
|
|
268
|
+
describe("runRemove — --agent flag + transport error paths", () => {
|
|
269
|
+
beforeEach(setup);
|
|
270
|
+
afterEach(teardown);
|
|
271
|
+
|
|
272
|
+
it("rejects --agent none with a validation error (no targets to delete from)", async () => {
|
|
273
|
+
await assert.rejects(
|
|
274
|
+
() =>
|
|
275
|
+
runRemove(
|
|
276
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "none", "@alice/pdf-helper"],
|
|
277
|
+
{ stdout },
|
|
278
|
+
),
|
|
279
|
+
(err) =>
|
|
280
|
+
err instanceof CliError &&
|
|
281
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
282
|
+
/--agent none has no effect on `skillrepo remove`/.test(err.message),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("--agent cursor performs a vendor-scoped delete: only the .agents/skills/ placement is removed", async () => {
|
|
287
|
+
// Seed the SAME skill into BOTH the cursor (.agents/skills/) and the
|
|
288
|
+
// claude (.claude/skills/) placements.
|
|
289
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["cursor"] });
|
|
290
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
|
|
291
|
+
const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
|
|
292
|
+
const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
293
|
+
assert.ok(existsSync(agentsDir), "precondition: cursor placement exists");
|
|
294
|
+
assert.ok(existsSync(claudeDir), "precondition: claude placement exists");
|
|
295
|
+
|
|
296
|
+
// Default server response is 200 removed. --agent cursor scopes the
|
|
297
|
+
// local delete to the cohort target only.
|
|
298
|
+
await runRemove(
|
|
299
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
|
|
300
|
+
{ stdout },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// The cursor placement is gone …
|
|
304
|
+
assert.ok(!existsSync(agentsDir), "--agent cursor must delete the .agents/skills/ placement");
|
|
305
|
+
// … but the claude placement is untouched (proves the scoping; a
|
|
306
|
+
// regression that deleted every placement would fail here).
|
|
307
|
+
assert.ok(
|
|
308
|
+
existsSync(claudeDir),
|
|
309
|
+
"--agent cursor must NOT delete the claude placement",
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("network failure exits 1 (network)", async () => {
|
|
314
|
+
// Connection-refused on the DELETE call → safeFetch wraps it as
|
|
315
|
+
// networkError (exit 1). removeSkillFromLibrary does not retry.
|
|
316
|
+
await assert.rejects(
|
|
317
|
+
() =>
|
|
318
|
+
runRemove(
|
|
319
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "@alice/pdf-helper"],
|
|
320
|
+
{ stdout },
|
|
321
|
+
),
|
|
322
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("removed + 2 local placements → pluralized 'directories' summary", async () => {
|
|
327
|
+
// Branch coverage: every prior test deletes exactly ONE directory,
|
|
328
|
+
// so the plural side of `director{y|ies}` on the
|
|
329
|
+
// `wasRemovedFromLibrary && localCount > 0` summary (remove.mjs
|
|
330
|
+
// line ~160) was never exercised. Seed the SAME skill into two
|
|
331
|
+
// distinct placement dirs (claude → .claude/skills/, cursor →
|
|
332
|
+
// .agents/skills/) and remove with both targets so localCount === 2.
|
|
333
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
|
|
334
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["cursor"] });
|
|
335
|
+
const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
336
|
+
const agentsDir = resolvePlacementDir("agentsProject", "pdf-helper");
|
|
337
|
+
assert.ok(existsSync(claudeDir) && existsSync(agentsDir), "precondition: both placements exist");
|
|
338
|
+
|
|
339
|
+
// Default server response is 200 removed.
|
|
340
|
+
await runRemove(
|
|
341
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude,cursor", "@alice/pdf-helper"],
|
|
342
|
+
{ stdout },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
assert.ok(!existsSync(claudeDir) && !existsSync(agentsDir), "both placements deleted");
|
|
346
|
+
assert.match(
|
|
347
|
+
stdout.text(),
|
|
348
|
+
/deleted 2 local directories/,
|
|
349
|
+
"two removed dirs must pluralize as 'directories'",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// NOTE: the `io.stdout ?? process.stdout` default (remove.mjs line 59) is
|
|
354
|
+
// intentionally NOT unit-tested. The only way to exercise it is to stub
|
|
355
|
+
// `process.stdout.write` across an `await`, which collides with node:test's
|
|
356
|
+
// TAP-over-stdout IPC and DEADLOCKS the runner (a Windows-only hang that
|
|
357
|
+
// burned a 10-minute CI job). See capture-stream.mjs's docstring. The
|
|
358
|
+
// default is a one-token defensive fallback; it isn't worth that pattern.
|
|
359
|
+
|
|
360
|
+
it("not-in-library + 2 local placements → pluralized 'directories' summary", async () => {
|
|
361
|
+
// Plural side of the OTHER summary branch (remove.mjs line ~176):
|
|
362
|
+
// the 404 not-in-library path with more than one local placement.
|
|
363
|
+
writeSkillDir(makeSkill("alice", "orphan"), { vendors: ["claudeCode"] });
|
|
364
|
+
writeSkillDir(makeSkill("alice", "orphan"), { vendors: ["cursor"] });
|
|
365
|
+
|
|
366
|
+
server.setRemoveResponse("alice", "orphan", {
|
|
367
|
+
status: 404,
|
|
368
|
+
body: { code: "not_in_library" },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
await runRemove(
|
|
372
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude,cursor", "@alice/orphan"],
|
|
373
|
+
{ stdout },
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "orphan")));
|
|
377
|
+
assert.ok(!existsSync(resolvePlacementDir("agentsProject", "orphan")));
|
|
378
|
+
assert.match(
|
|
379
|
+
stdout.text(),
|
|
380
|
+
/wasn't in your library — deleted 2 local directories/,
|
|
381
|
+
"two removed dirs on the not-in-library path must pluralize as 'directories'",
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ── Coverage gap closure: the deleteLastSyncEntry catch swallow ─────────
|
|
387
|
+
//
|
|
388
|
+
// remove.mjs step 2.5 wraps `deleteLastSyncEntry` in try/catch and
|
|
389
|
+
// swallows any throw (lines 121-125): a stale `.last-sync` baseline is
|
|
390
|
+
// recoverable via the next `update`, so a write failure there must NOT
|
|
391
|
+
// fail the command after the user's primary intent (skill gone from
|
|
392
|
+
// library + disk) was already honored.
|
|
393
|
+
//
|
|
394
|
+
// The catch runs ONLY when `deleteLastSyncEntry` throws, which requires
|
|
395
|
+
// readLastSync to SUCCEED (a valid `.last-sync` WITH this skill's entry)
|
|
396
|
+
// AND writeLastSync to FAIL. We force the write failure by making the
|
|
397
|
+
// `~/.claude/skillrepo/` directory read+execute only (0o555): the read
|
|
398
|
+
// succeeds (the file is already there) but the atomic write's temp-file
|
|
399
|
+
// creation inside that dir fails with EACCES.
|
|
400
|
+
describe("runRemove — deleteLastSyncEntry write failure is swallowed", () => {
|
|
401
|
+
beforeEach(setup);
|
|
402
|
+
afterEach(teardown);
|
|
403
|
+
|
|
404
|
+
it("does not fail the command when purging the .last-sync entry throws (POSIX)", async (t) => {
|
|
405
|
+
if (process.platform === "win32") {
|
|
406
|
+
// chmod 0o555 does not block writes on Windows (the mode bits
|
|
407
|
+
// don't map to the ACL layer — see fs-utils.mjs writeFileAtomic),
|
|
408
|
+
// so this fault-injection technique can't reproduce the EACCES.
|
|
409
|
+
// Skipped rather than silently passing for the wrong reason.
|
|
410
|
+
t.skip("chmod does not gate writes on win32");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Seed a valid `.last-sync` that contains this skill's entry so
|
|
415
|
+
// readLastSync succeeds and the key IS present (both preconditions
|
|
416
|
+
// for deleteLastSyncEntry to reach its writeLastSync call).
|
|
417
|
+
writeLastSync({
|
|
418
|
+
etag: "etag-1",
|
|
419
|
+
syncedAt: new Date().toISOString(),
|
|
420
|
+
skills: {
|
|
421
|
+
"alice/pdf-helper": { version: "1.0.0", shas: { "SKILL.md": "x" } },
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
assert.ok(
|
|
425
|
+
readLastSync().skills["alice/pdf-helper"],
|
|
426
|
+
"precondition: .last-sync must carry the skill entry",
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// Pre-write the local skill so the happy-path delete still reports
|
|
430
|
+
// success (proving the command completed despite the swallowed throw).
|
|
431
|
+
writeSkillDir(makeSkill("alice", "pdf-helper"), { vendors: ["claudeCode"] });
|
|
432
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
433
|
+
assert.ok(existsSync(dir));
|
|
434
|
+
|
|
435
|
+
// Make the `.last-sync` parent dir read+execute only so the atomic
|
|
436
|
+
// temp-file write fails with EACCES → writeLastSync throws →
|
|
437
|
+
// deleteLastSyncEntry throws → remove.mjs's catch swallows it.
|
|
438
|
+
const skillrepoDir = join(process.env.HOME, ".claude", "skillrepo");
|
|
439
|
+
chmodSync(skillrepoDir, 0o555);
|
|
440
|
+
try {
|
|
441
|
+
// Default server response is 200 removed. Must resolve (not reject)
|
|
442
|
+
// — the swallowed throw must not surface to the caller.
|
|
443
|
+
await assert.doesNotReject(
|
|
444
|
+
runRemove(
|
|
445
|
+
["--key", VALID_KEY, "--url", serverUrl, "@alice/pdf-helper"],
|
|
446
|
+
{ stdout },
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
} finally {
|
|
450
|
+
// Restore write perms so teardown's rmSync can clean the sandbox.
|
|
451
|
+
chmodSync(skillrepoDir, 0o755);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The command still reported success and deleted the local files.
|
|
455
|
+
assert.ok(!existsSync(dir), "local files must still be deleted");
|
|
456
|
+
assert.match(
|
|
457
|
+
stdout.text(),
|
|
458
|
+
/Removed @alice\/pdf-helper from your library/,
|
|
459
|
+
"the command must report success despite the swallowed .last-sync write failure",
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
|
|
11
11
|
import { runSearch } from "../../commands/search.mjs";
|
|
12
|
-
import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
|
|
12
|
+
import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_NETWORK } from "../../lib/errors.mjs";
|
|
13
13
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
14
14
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
15
15
|
import {
|
|
@@ -160,6 +160,194 @@ describe("runSearch — happy path", () => {
|
|
|
160
160
|
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "--limit", "1", "test"], { stdout });
|
|
161
161
|
assert.match(stdout.text(), /Showing 1 of 100/);
|
|
162
162
|
});
|
|
163
|
+
|
|
164
|
+
it("--json --semantic sets semantic:true / semanticSupported:false and writes NO note to stderr", async () => {
|
|
165
|
+
// In JSON mode runSearch writes the structured object and returns
|
|
166
|
+
// BEFORE the `if (semantic)` stderr-note branch (search.mjs ~83-111).
|
|
167
|
+
// So a script consuming --json sees the no-op surfaced in the JSON
|
|
168
|
+
// fields, and stderr stays clean (no human-prose note interleaved
|
|
169
|
+
// with machine output). Assert BOTH halves of the contract.
|
|
170
|
+
server.setSearchResponse({
|
|
171
|
+
skills: [makeResult("alice", "pdf-helper")],
|
|
172
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
173
|
+
});
|
|
174
|
+
await runSearch(
|
|
175
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json", "--semantic", "pdf"],
|
|
176
|
+
{ stdout, stderr },
|
|
177
|
+
);
|
|
178
|
+
const json = JSON.parse(stdout.text());
|
|
179
|
+
assert.equal(json.semantic, true);
|
|
180
|
+
assert.equal(json.semanticSupported, false);
|
|
181
|
+
// The non-JSON branch is the ONLY place the note is emitted, so
|
|
182
|
+
// JSON mode must leave stderr empty of it.
|
|
183
|
+
assert.doesNotMatch(stderr.text(), /reserved for v1\.1/);
|
|
184
|
+
assert.equal(stderr.text(), "");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("missing pagination → footer total falls back to the number of skills shown", async () => {
|
|
188
|
+
// Regression guard for the #1923 search-undercount fix. The production
|
|
189
|
+
// server route normally emits `pagination: {total,...}`
|
|
190
|
+
// (src/app/api/v1/skills/search/route.ts), but a pagination-less
|
|
191
|
+
// response used to make `search` print "0 results" beneath a visible
|
|
192
|
+
// row: searchSkills (http.mjs) defaulted absent pagination to
|
|
193
|
+
// `{ total: 0, ... }`, fabricating a zero count. http.mjs now defaults
|
|
194
|
+
// `total` to `skills.length` (http.mjs ~1129), so a pagination-less
|
|
195
|
+
// response renders the honest count. This asserts the corrected
|
|
196
|
+
// behavior (1 row ⇒ "1 result", never "0 results"); it goes RED again
|
|
197
|
+
// only if that normalization regresses.
|
|
198
|
+
server.setSearchResponse({
|
|
199
|
+
skills: [makeResult("alice", "only-one", 42)],
|
|
200
|
+
pagination: null,
|
|
201
|
+
});
|
|
202
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "solo"], { stdout });
|
|
203
|
+
const out = stdout.text();
|
|
204
|
+
// Must not crash and must render the row.
|
|
205
|
+
assert.match(out, /only-one/);
|
|
206
|
+
// One skill shown → exactly one result reported.
|
|
207
|
+
assert.match(out, /\b1 result\b/);
|
|
208
|
+
// And it must NOT under-report as zero.
|
|
209
|
+
assert.doesNotMatch(out, /\b0 results\b/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("renders an em-dash for a result with a non-number install count", async () => {
|
|
213
|
+
// formatInstallCount (search.mjs ~178) returns "—" for anything
|
|
214
|
+
// that isn't a finite number. A result whose `installs` is null
|
|
215
|
+
// must render the dash in that column, not "null"/"0"/NaN.
|
|
216
|
+
const result = makeResult("alice", "no-installs", 0);
|
|
217
|
+
result.installs = null;
|
|
218
|
+
server.setSearchResponse({
|
|
219
|
+
skills: [result],
|
|
220
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
221
|
+
});
|
|
222
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
|
|
223
|
+
const out = stdout.text();
|
|
224
|
+
assert.match(out, /no-installs/);
|
|
225
|
+
assert.match(out, /—/);
|
|
226
|
+
// Sanity: the raw null must not have leaked into the table.
|
|
227
|
+
assert.doesNotMatch(out, /\bnull\b/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("truncates a description longer than the 60-char column budget", async () => {
|
|
231
|
+
// truncate (search.mjs ~185) returns the input unchanged when it
|
|
232
|
+
// fits, but slices to n-1 chars + "…" when it exceeds the budget.
|
|
233
|
+
// The existing happy-path results all use the short
|
|
234
|
+
// `${name} description` string (< 60 chars), so only the no-op arm
|
|
235
|
+
// ran. A >60-char description exercises the truncating arm.
|
|
236
|
+
const result = makeResult("alice", "verbose", 10);
|
|
237
|
+
result.description = "x".repeat(200);
|
|
238
|
+
server.setSearchResponse({
|
|
239
|
+
skills: [result],
|
|
240
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
241
|
+
});
|
|
242
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
|
|
243
|
+
const out = stdout.text();
|
|
244
|
+
assert.match(out, /verbose/);
|
|
245
|
+
// The ellipsis marker proves the truncating branch ran.
|
|
246
|
+
assert.match(out, /…/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("renders the em-dash version + empty description for a result missing both", async () => {
|
|
250
|
+
// printTable (search.mjs ~135/137) falls back to "—" when
|
|
251
|
+
// `s.version` is empty and to "" when `s.description` is empty.
|
|
252
|
+
// The makeResult helper always supplies both, so the false sides
|
|
253
|
+
// of those `||` defaults never ran. A result with empty version
|
|
254
|
+
// and description exercises them.
|
|
255
|
+
const result = makeResult("alice", "bare", 5);
|
|
256
|
+
result.version = "";
|
|
257
|
+
result.description = "";
|
|
258
|
+
server.setSearchResponse({
|
|
259
|
+
skills: [result],
|
|
260
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
261
|
+
});
|
|
262
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout });
|
|
263
|
+
const out = stdout.text();
|
|
264
|
+
assert.match(out, /bare/);
|
|
265
|
+
// Version column shows the em-dash placeholder.
|
|
266
|
+
assert.match(out, /—/);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── streamColumns coverage ──────────────────────────────────────────────
|
|
271
|
+
//
|
|
272
|
+
// streamColumns (search.mjs ~168) mirrors list.mjs: it reads the injected
|
|
273
|
+
// stream's numeric `.columns` first, then falls back to
|
|
274
|
+
// process.stdout.columns, then to a 100-col default. The happy-path tests
|
|
275
|
+
// use the plain capture stream (no `.columns`) with process.stdout.columns
|
|
276
|
+
// unset in the test runner, so only the final `return 100` default ran.
|
|
277
|
+
// These two tests cover the first two arms.
|
|
278
|
+
|
|
279
|
+
/** A capture stream that carries a fixed numeric column width. */
|
|
280
|
+
function createWidthCaptureStream(columns = 120) {
|
|
281
|
+
const stream = createCaptureStream();
|
|
282
|
+
stream.columns = columns;
|
|
283
|
+
return stream;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
describe("runSearch — streamColumns", () => {
|
|
287
|
+
beforeEach(setup);
|
|
288
|
+
afterEach(teardown);
|
|
289
|
+
|
|
290
|
+
it("uses the injected stream's .columns when present", async () => {
|
|
291
|
+
const wideStdout = createWidthCaptureStream(120);
|
|
292
|
+
server.setSearchResponse({
|
|
293
|
+
skills: [makeResult("alice", "pdf-helper", 500)],
|
|
294
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
295
|
+
});
|
|
296
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], {
|
|
297
|
+
stdout: wideStdout,
|
|
298
|
+
});
|
|
299
|
+
// First streamColumns arm (out.columns numeric > 0) ran; table still
|
|
300
|
+
// renders correctly.
|
|
301
|
+
assert.match(wideStdout.text(), /pdf-helper/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("falls back to the 100-col default for a narrow (<=60) terminal width", async () => {
|
|
305
|
+
// computeColWidths (search.mjs ~155) uses `terminalColumns > 60 ?
|
|
306
|
+
// Math.min(..., 120) : 100`. A stream reporting a width <= 60
|
|
307
|
+
// exercises the `: 100` arm — the narrow-terminal floor — which the
|
|
308
|
+
// wide-stream and default-path tests don't reach.
|
|
309
|
+
const narrowStdout = createWidthCaptureStream(40);
|
|
310
|
+
server.setSearchResponse({
|
|
311
|
+
skills: [makeResult("alice", "pdf-helper", 500)],
|
|
312
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
313
|
+
});
|
|
314
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], {
|
|
315
|
+
stdout: narrowStdout,
|
|
316
|
+
});
|
|
317
|
+
assert.match(narrowStdout.text(), /pdf-helper/);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe("runSearch — streamColumns process.stdout fallback", () => {
|
|
322
|
+
let stdoutColumnsSnapshot;
|
|
323
|
+
let hadOwnColumns;
|
|
324
|
+
|
|
325
|
+
beforeEach(async () => {
|
|
326
|
+
await setup();
|
|
327
|
+
// The injected capture stream has no `.columns`, so streamColumns
|
|
328
|
+
// falls through to process.stdout.columns. Force a numeric value
|
|
329
|
+
// there for the duration of this test to cover that second arm.
|
|
330
|
+
hadOwnColumns = Object.prototype.hasOwnProperty.call(process.stdout, "columns");
|
|
331
|
+
stdoutColumnsSnapshot = process.stdout.columns;
|
|
332
|
+
process.stdout.columns = 90;
|
|
333
|
+
});
|
|
334
|
+
afterEach(async () => {
|
|
335
|
+
if (hadOwnColumns) {
|
|
336
|
+
process.stdout.columns = stdoutColumnsSnapshot;
|
|
337
|
+
} else {
|
|
338
|
+
delete process.stdout.columns;
|
|
339
|
+
}
|
|
340
|
+
await teardown();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("uses process.stdout.columns when the injected stream has none", async () => {
|
|
344
|
+
server.setSearchResponse({
|
|
345
|
+
skills: [makeResult("alice", "pdf-helper", 500)],
|
|
346
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
347
|
+
});
|
|
348
|
+
await runSearch(["--key", VALID_KEY, "--url", serverUrl, "pdf"], { stdout });
|
|
349
|
+
assert.match(stdout.text(), /pdf-helper/);
|
|
350
|
+
});
|
|
163
351
|
});
|
|
164
352
|
|
|
165
353
|
describe("runSearch — error paths", () => {
|
|
@@ -207,4 +395,18 @@ describe("runSearch — error paths", () => {
|
|
|
207
395
|
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
208
396
|
);
|
|
209
397
|
});
|
|
398
|
+
|
|
399
|
+
it("rejects with a network CliError when GET /skills/search returns 500", async () => {
|
|
400
|
+
// search goes straight to searchSkills — no prior validate call —
|
|
401
|
+
// so the one-shot forced status fires on the search GET itself.
|
|
402
|
+
// searchSkills uses retry:true, but 500 is NOT transient
|
|
403
|
+
// (TRANSIENT_STATUS_CODES = 429/502/503/504), so withRetry
|
|
404
|
+
// returns it immediately and mapErrorResponse maps `status >= 500`
|
|
405
|
+
// to networkError → EXIT_NETWORK (exit 1). Real mapped value.
|
|
406
|
+
server.setForcedStatus(500, { error: "boom" });
|
|
407
|
+
await assert.rejects(
|
|
408
|
+
() => runSearch(["--key", VALID_KEY, "--url", serverUrl, "test"], { stdout, stderr }),
|
|
409
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
210
412
|
});
|