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.
Files changed (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -22,6 +22,7 @@ import {
22
22
  EXIT_VALIDATION,
23
23
  EXIT_AUTH,
24
24
  EXIT_SCOPE,
25
+ EXIT_NETWORK,
25
26
  } from "../../lib/errors.mjs";
26
27
  import { createMockServer } from "../e2e/mock-server.mjs";
27
28
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
@@ -191,11 +192,11 @@ describe("runPublish — error paths", () => {
191
192
  });
192
193
  });
193
194
 
194
- it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), billing hint surfaces", async () => {
195
+ it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), no CLI-side hint", async () => {
195
196
  // Real-world regression scenario: an account over its skill
196
197
  // quota tries to publish. Server returns 403 with code
197
198
  // `plan_limit`. The CLI MUST classify this as exit 5
198
- // validation with the billing hint, NOT exit 2 authError
199
+ // validation with the self-contained server message, NOT exit 2 authError
199
200
  // ("contact support") — a pre-review version of the code
200
201
  // consumed the 403 body and let mapErrorResponse re-parse, but
201
202
  // the body was empty by then so plan_limit fell through to
@@ -210,7 +211,7 @@ describe("runPublish — error paths", () => {
210
211
  await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
211
212
  assert.ok(err instanceof CliError);
212
213
  assert.equal(err.exitCode, EXIT_VALIDATION);
213
- assert.match(err.hint ?? "", /Upgrade your plan|billing/);
214
+ assert.equal(err.hint, undefined); // server plan_limit message is self-contained (#2239)
214
215
  return true;
215
216
  });
216
217
  });
@@ -276,6 +277,66 @@ describe("runPublish — error paths", () => {
276
277
  (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
277
278
  );
278
279
  });
280
+
281
+ it("extra positional argument → CliError EXIT_VALIDATION 'Unexpected extra argument'", async () => {
282
+ // The arg parser accepts exactly ONE positional (the identifier).
283
+ // A second positional must be rejected before any HTTP call —
284
+ // `publish` takes a single @owner/name. The identifier comes
285
+ // first so the parser sees the SECOND token as the surplus arg.
286
+ await assert.rejects(
287
+ runPublish(
288
+ [`@alice/my-skill`, `@alice/other`, "--key", VALID_KEY, "--url", serverUrl],
289
+ { stdout },
290
+ ),
291
+ (err) =>
292
+ err instanceof CliError &&
293
+ err.exitCode === EXIT_VALIDATION &&
294
+ /Unexpected extra argument/.test(err.message),
295
+ );
296
+ });
297
+
298
+ it("generic 403 with a `scope` code (not publish_not_permitted/plan_limit) → CliError EXIT_SCOPE", async () => {
299
+ // setSkillVisibility's inline 403 dispatch (http.mjs ~1254) mirrors
300
+ // mapErrorResponse: any code containing "scope" maps to scopeError
301
+ // (exit 4), NOT authError (exit 2). This is the documented contract
302
+ // — assert it directly so a regression to authError is caught. The
303
+ // code here ("insufficient_scope") is neither publish_not_permitted
304
+ // nor plan_limit, so it takes the generic-scope branch.
305
+ server.setPublishResponse("alice", "my-skill", {
306
+ status: 403,
307
+ body: {
308
+ error: "Your access key lacks the registry:write scope.",
309
+ code: "insufficient_scope",
310
+ },
311
+ });
312
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
313
+ assert.ok(err instanceof CliError);
314
+ assert.equal(err.exitCode, EXIT_SCOPE);
315
+ assert.match(err.hint ?? "", /write-scoped key/);
316
+ return true;
317
+ });
318
+ });
319
+
320
+ it("200 with an unexpected `action` value → networkError EXIT_NETWORK", async () => {
321
+ // A 200 whose `action` is none of published/unpublished/unchanged
322
+ // is a protocol violation — the server promised a known
323
+ // discriminant. setSkillVisibility (http.mjs ~1212) throws a
324
+ // networkError naming the bogus action. Assert exit 1 and that the
325
+ // message surfaces the unexpected value, not a silent success.
326
+ server.setPublishResponse("alice", "my-skill", {
327
+ status: 200,
328
+ body: {
329
+ action: "teleported",
330
+ skill: { owner: "alice", name: "my-skill" },
331
+ },
332
+ });
333
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
334
+ assert.ok(err instanceof CliError);
335
+ assert.equal(err.exitCode, EXIT_NETWORK);
336
+ assert.match(err.message, /unexpected action "teleported"/);
337
+ return true;
338
+ });
339
+ });
279
340
  });
280
341
 
281
342
  // ── runUnpublish ────────────────────────────────────────────────────────
@@ -417,4 +478,76 @@ describe("runUnpublish — error paths", () => {
417
478
  /Missing skill identifier/.test(err.message),
418
479
  );
419
480
  });
481
+
482
+ it("401 unauthorized → CliError EXIT_AUTH", async () => {
483
+ // Mirror the publish-side 401 vector. A 401 on unpublish flows
484
+ // through mapErrorResponse (http.mjs ~305) → authError → exit 2.
485
+ server.setUnpublishResponse("alice", "my-skill", {
486
+ status: 401,
487
+ body: { error: "Invalid access key" },
488
+ });
489
+ await assert.rejects(
490
+ runUnpublish(baseArgv(), { stdout }),
491
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
492
+ );
493
+ });
494
+
495
+ it("403 unpublish_not_permitted → CliError EXIT_SCOPE (code confirmed)", async () => {
496
+ // The unpublish 403 endpoint-permission code is
497
+ // `unpublish_not_permitted` (http.mjs ~1241, VisibilityResultForbidden
498
+ // typedef ~1145). It maps to the `forbidden` outcome → scopeError
499
+ // → exit 4. Asserts the exit-code mapping for the bare code without
500
+ // the hint-dedup detail covered by the action-only-hint test above.
501
+ server.setUnpublishResponse("alice", "my-skill", {
502
+ status: 403,
503
+ body: {
504
+ error: "You do not have permission to unpublish this skill.",
505
+ code: "unpublish_not_permitted",
506
+ },
507
+ });
508
+ await assert.rejects(
509
+ runUnpublish(baseArgv(), { stdout }),
510
+ (err) =>
511
+ err instanceof CliError &&
512
+ err.exitCode === EXIT_SCOPE &&
513
+ /canPublish/.test(err.hint ?? ""),
514
+ );
515
+ });
516
+
517
+ it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), no CLI-side hint", async () => {
518
+ // Same shared `setSkillVisibility` 403 dispatch as publish: a
519
+ // plan_limit 403 must classify as exit 5 validation with the
520
+ // self-contained server message, never exit 2 authError. Mirrors the publish-side
521
+ // regression guard for the unpublish verb.
522
+ server.setUnpublishResponse("alice", "my-skill", {
523
+ status: 403,
524
+ body: {
525
+ error: "You are over your library skill quota.",
526
+ code: "plan_limit",
527
+ },
528
+ });
529
+ await assert.rejects(runUnpublish(baseArgv(), { stdout }), (err) => {
530
+ assert.ok(err instanceof CliError);
531
+ assert.equal(err.exitCode, EXIT_VALIDATION);
532
+ assert.equal(err.hint, undefined); // server plan_limit message is self-contained (#2239)
533
+ return true;
534
+ });
535
+ });
536
+
537
+ it("extra positional argument → CliError EXIT_VALIDATION 'Unexpected extra argument'", async () => {
538
+ // `unpublish` takes a single @owner/name. A second positional is
539
+ // rejected by the arg parser before any HTTP call (unpublish.mjs
540
+ // acceptPositional, ~49). The identifier comes first; the SECOND
541
+ // token is the surplus arg.
542
+ await assert.rejects(
543
+ runUnpublish(
544
+ [`@alice/my-skill`, `@alice/other`, "--key", VALID_KEY, "--url", serverUrl],
545
+ { stdout },
546
+ ),
547
+ (err) =>
548
+ err instanceof CliError &&
549
+ err.exitCode === EXIT_VALIDATION &&
550
+ /Unexpected extra argument/.test(err.message),
551
+ );
552
+ });
420
553
  });
@@ -18,7 +18,12 @@ import { join } from "node:path";
18
18
  import { tmpdir } from "node:os";
19
19
 
20
20
  import { runPush } from "../../commands/push.mjs";
21
- import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
21
+ import {
22
+ CliError,
23
+ EXIT_AUTH,
24
+ EXIT_SCOPE,
25
+ EXIT_VALIDATION,
26
+ } from "../../lib/errors.mjs";
22
27
  import { createMockServer } from "../e2e/mock-server.mjs";
23
28
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
24
29
  import {
@@ -142,6 +147,52 @@ describe("runPush — happy paths", () => {
142
147
  assert.match(stdout.text(), /Released @alice\/my-skill v1\.1.*minor bump/);
143
148
  });
144
149
 
150
+ it("--json falls back to frontmatter name + null owner/version when server omits `skill`", async () => {
151
+ // The server can return a documented action with a null/absent
152
+ // `skill` (e.g. a minimal 201). The JSON reporter must degrade
153
+ // gracefully: owner/version → null, name → the SKILL.md frontmatter
154
+ // name. This covers the `result.skill?.owner ?? null`,
155
+ // `?? frontmatter.name`, and `?? null` branches in --json mode.
156
+ file("SKILL.md", VALID_SKILL_MD);
157
+ server.setPushResponse({
158
+ status: 201,
159
+ body: { action: "created", bump: null, skill: null },
160
+ });
161
+
162
+ await runPush(
163
+ ["--key", VALID_KEY, "--url", serverUrl, "--json", "skill"],
164
+ { stdout },
165
+ );
166
+
167
+ const json = JSON.parse(stdout.text());
168
+ assert.equal(json.action, "created");
169
+ assert.equal(json.owner, null, "owner falls back to null when skill is absent");
170
+ assert.equal(json.name, "my-skill", "name falls back to the frontmatter name");
171
+ assert.equal(json.version, null, "version falls back to null");
172
+ });
173
+
174
+ it("human output falls back to @?/<frontmatter-name> and v1.0 when server omits `skill` (created)", async () => {
175
+ // Mirror of the --json fallback test for the human report path:
176
+ // `@${owner ?? "?"}/${name ?? frontmatter.name}` and the created
177
+ // branch's `version ?? "1.0"`.
178
+ file("SKILL.md", VALID_SKILL_MD);
179
+ server.setPushResponse({
180
+ status: 201,
181
+ body: { action: "created", bump: null, skill: null },
182
+ });
183
+
184
+ await runPush(
185
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
186
+ { stdout },
187
+ );
188
+
189
+ assert.match(
190
+ stdout.text(),
191
+ /Created @\?\/my-skill v1\.0 \(1 file\)/,
192
+ "human report uses @?/<frontmatter-name> and the v1.0 version fallback",
193
+ );
194
+ });
195
+
145
196
  it("reports 'no changes' when server returns action=unchanged", async () => {
146
197
  file("SKILL.md", VALID_SKILL_MD);
147
198
  server.setPushResponse({
@@ -286,4 +337,232 @@ describe("runPush — flags", () => {
286
337
  // Default server response is 201 created.
287
338
  assert.match(stdout.text(), /Created @mock\/test-skill/);
288
339
  });
340
+
341
+ it("rejects a second positional argument with EXIT_VALIDATION", async () => {
342
+ // `resolveFlags`' acceptPositional callback claims the first bare
343
+ // arg as the skill path; a second bare arg has nowhere to go and
344
+ // must fail loudly rather than be silently dropped. The dispatch
345
+ // happens during argv parsing — before any directory stat or HTTP
346
+ // — so no SKILL.md fixture is required.
347
+ await assert.rejects(
348
+ runPush(
349
+ ["--key", VALID_KEY, "--url", serverUrl, "./dir", "extra"],
350
+ { stdout },
351
+ ),
352
+ (err) =>
353
+ err instanceof CliError &&
354
+ err.exitCode === EXIT_VALIDATION &&
355
+ /Unexpected extra argument/.test(err.message),
356
+ );
357
+ });
358
+ });
359
+
360
+ // ── runPush — server-side error mapping (through the command) ──────────
361
+ //
362
+ // Every case below configures the mock's POST /api/v1/library response
363
+ // via `setPushResponse` and drives the FULL command path
364
+ // (`runPush` → `pushSkill` → `mapErrorResponse`). The assertions pin the
365
+ // exit code each documented server outcome MUST surface, per the
366
+ // `mapErrorResponse` 401/403/404/429/5xx taxonomy in `src/lib/http.mjs`
367
+ // and the exit-code matrix in `src/lib/errors.mjs`. These mirror the
368
+ // `pushSkill` unit tests in `src/test/lib/http.test.mjs` but exercise the
369
+ // command wrapper rather than the raw lib function.
370
+
371
+ describe("runPush — server error mapping", () => {
372
+ beforeEach(setup);
373
+ afterEach(teardown);
374
+
375
+ it("maps server 401 to EXIT_AUTH", async () => {
376
+ file("SKILL.md", VALID_SKILL_MD);
377
+ server.setPushResponse({
378
+ status: 401,
379
+ body: { error: "Invalid access key", code: "unauthorized" },
380
+ });
381
+ await assert.rejects(
382
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
383
+ (err) =>
384
+ err instanceof CliError &&
385
+ err.exitCode === EXIT_AUTH &&
386
+ /Invalid access key/.test(err.message),
387
+ );
388
+ });
389
+
390
+ it("maps server 403 scope to EXIT_SCOPE", async () => {
391
+ file("SKILL.md", VALID_SKILL_MD);
392
+ server.setPushResponse({
393
+ status: 403,
394
+ body: { error: "Write scope required", code: "scope_required" },
395
+ });
396
+ await assert.rejects(
397
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
398
+ (err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
399
+ );
400
+ });
401
+
402
+ it("maps server 403 plan_limit to EXIT_VALIDATION (NOT auth)", async () => {
403
+ // mapErrorResponse explicitly disambiguates plan_limit from the
404
+ // generic-403 authError path so the user sees the plan-limit message, not
405
+ // a "contact support" hint. Mirrors the http.test.mjs assertion.
406
+ file("SKILL.md", VALID_SKILL_MD);
407
+ server.setPushResponse({
408
+ status: 403,
409
+ body: {
410
+ error: "Your free plan allows up to 5 library skills.",
411
+ code: "plan_limit",
412
+ },
413
+ });
414
+ await assert.rejects(
415
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
416
+ (err) =>
417
+ err instanceof CliError &&
418
+ err.exitCode === EXIT_VALIDATION &&
419
+ /plan/i.test(err.message),
420
+ );
421
+ });
422
+
423
+ it("maps server 409 source_conflict to EXIT_VALIDATION (generic 4xx)", async () => {
424
+ // 409 has no dedicated branch in mapErrorResponse — it falls
425
+ // through to the generic-4xx `validationError(message)` return.
426
+ file("SKILL.md", VALID_SKILL_MD);
427
+ server.setPushResponse({
428
+ status: 409,
429
+ body: {
430
+ error: "A skill with this name is owned by another source.",
431
+ code: "source_conflict",
432
+ },
433
+ });
434
+ await assert.rejects(
435
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
436
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
437
+ );
438
+ });
439
+
440
+ it("maps server 422 idempotency_key_reused to EXIT_VALIDATION", async () => {
441
+ // 422 has no dedicated branch either — generic-4xx → validationError.
442
+ file("SKILL.md", VALID_SKILL_MD);
443
+ server.setPushResponse({
444
+ status: 422,
445
+ body: {
446
+ error: "Idempotency key was reused with a different request body.",
447
+ code: "idempotency_key_reused",
448
+ },
449
+ });
450
+ await assert.rejects(
451
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
452
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
453
+ );
454
+ });
455
+
456
+ it("maps server 413 payload_too_large to EXIT_VALIDATION (generic 4xx)", async () => {
457
+ // 413 has NO explicit branch in mapErrorResponse (only 401/403/404/
458
+ // 429/5xx are special-cased). It therefore falls through to the
459
+ // generic-4xx `return validationError(message)` → exit 5. This pins
460
+ // the ACTUAL produced code; if the source ever grows a dedicated
461
+ // 413 branch with a different exit code, this fails and forces a
462
+ // conscious update.
463
+ file("SKILL.md", VALID_SKILL_MD);
464
+ server.setPushResponse({
465
+ status: 413,
466
+ body: { error: "Payload exceeds the 4.5MB limit.", code: "payload_too_large" },
467
+ });
468
+ await assert.rejects(
469
+ runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
470
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
471
+ );
472
+ });
473
+ });
474
+
475
+ // ── runPush — multipart wire format ────────────────────────────────────
476
+ //
477
+ // The mock server doesn't parse multipart; `getLastPostBody()` returns
478
+ // the RAW request body. For a multipart push the body is the
479
+ // `multipart/form-data` byte stream (undici serializes the FormData),
480
+ // which JSON.parse can't handle, so the mock stores it verbatim as a
481
+ // string. We assert the wire format directly against that raw string:
482
+ // a part for SKILL.md, a part whose Content-Disposition filename carries
483
+ // the nested POSIX relative path (RFC 7578), and that the client sent a
484
+ // multipart/form-data Content-Type.
485
+
486
+ describe("runPush — multipart wire format", () => {
487
+ beforeEach(setup);
488
+ afterEach(teardown);
489
+
490
+ it("encodes SKILL.md + nested file with RFC 7578 filename paths", async () => {
491
+ file("SKILL.md", VALID_SKILL_MD);
492
+ file("references/intro.md", "intro body");
493
+
494
+ await runPush(
495
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
496
+ { stdout },
497
+ );
498
+
499
+ // Sanity: the push succeeded (default 201 created response).
500
+ assert.match(stdout.text(), /Created @mock\/test-skill/);
501
+
502
+ const rawBody = server.getLastPostBody();
503
+ assert.equal(
504
+ typeof rawBody,
505
+ "string",
506
+ "multipart body should be captured as a raw string (JSON.parse fails on multipart)",
507
+ );
508
+
509
+ // Every part is appended under the `files` field name.
510
+ assert.match(
511
+ rawBody,
512
+ /Content-Disposition: form-data; name="files"/,
513
+ "expected a multipart part for the `files` field",
514
+ );
515
+
516
+ // The root SKILL.md must carry filename="SKILL.md" (no path prefix);
517
+ // the server identifies SKILL.md by exact-match filename.
518
+ assert.match(
519
+ rawBody,
520
+ /filename="SKILL\.md"/,
521
+ "expected a part whose Content-Disposition filename is exactly SKILL.md",
522
+ );
523
+
524
+ // The nested file must preserve its POSIX skill-relative path in the
525
+ // filename parameter (RFC 7578) so the server reconstructs the tree.
526
+ assert.match(
527
+ rawBody,
528
+ /filename="references\/intro\.md"/,
529
+ "expected the nested file's filename to carry the relative path references/intro.md",
530
+ );
531
+
532
+ // The raw bytes of the nested file must be in the body.
533
+ assert.ok(
534
+ rawBody.includes("intro body"),
535
+ "expected the nested file content to be present in the multipart body",
536
+ );
537
+ });
538
+
539
+ it("sends a multipart/form-data Content-Type (server accepts; boundary present)", async () => {
540
+ file("SKILL.md", VALID_SKILL_MD);
541
+ file("references/intro.md", "intro body");
542
+
543
+ // The mock's POST /api/v1/library handler returns 400
544
+ // invalid_multipart UNLESS the inbound Content-Type includes
545
+ // `multipart/form-data` (see mock-server.mjs). A successful 201 is
546
+ // therefore positive proof the client set that Content-Type — if
547
+ // pushSkill had set Content-Type to JSON (or omitted the boundary so
548
+ // undici couldn't infer multipart), this would surface as a
549
+ // validationError, not a "Created" success.
550
+ await runPush(
551
+ ["--key", VALID_KEY, "--url", serverUrl, "skill"],
552
+ { stdout },
553
+ );
554
+ assert.match(stdout.text(), /Created @mock\/test-skill/);
555
+
556
+ // Belt-and-suspenders at the wire level: undici emits a boundary
557
+ // delimiter (`--<boundary>`) before each part, which proves the body
558
+ // is genuinely multipart-encoded and not, say, a raw JSON blob the
559
+ // server happened to accept.
560
+ const rawBody = server.getLastPostBody();
561
+ assert.equal(typeof rawBody, "string");
562
+ assert.match(
563
+ rawBody,
564
+ /^--[-A-Za-z0-9]+\r?\n/m,
565
+ "expected a multipart boundary delimiter at the start of a part",
566
+ );
567
+ });
289
568
  });