@verdaccio/e2e-cli 3.0.0 → 3.2.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.
Files changed (209) hide show
  1. package/bin/e2e-cli.js +6 -26
  2. package/build/adapters/adapter.d.ts +1 -0
  3. package/build/adapters/bun.d.ts +2 -0
  4. package/build/adapters/deno.d.ts +2 -0
  5. package/build/adapters/index.d.ts +5 -0
  6. package/build/adapters/npm.d.ts +2 -0
  7. package/build/adapters/pnpm.d.ts +2 -0
  8. package/build/adapters/yarn-modern.d.ts +2 -0
  9. package/build/cjs/_virtual/_rolldown/runtime.cjs +23 -0
  10. package/build/cjs/adapters/bun.cjs +64 -0
  11. package/build/cjs/adapters/bun.cjs.map +1 -0
  12. package/build/cjs/adapters/deno.cjs +61 -0
  13. package/build/cjs/adapters/deno.cjs.map +1 -0
  14. package/build/cjs/adapters/index.cjs +10 -0
  15. package/build/cjs/adapters/npm.cjs +85 -0
  16. package/build/cjs/adapters/npm.cjs.map +1 -0
  17. package/build/cjs/adapters/pnpm.cjs +114 -0
  18. package/build/cjs/adapters/pnpm.cjs.map +1 -0
  19. package/build/cjs/adapters/yarn-modern.cjs +207 -0
  20. package/build/cjs/adapters/yarn-modern.cjs.map +1 -0
  21. package/build/cjs/index.cjs +186 -11
  22. package/build/cjs/index.cjs.map +1 -0
  23. package/build/cjs/reporter.cjs +113 -0
  24. package/build/cjs/reporter.cjs.map +1 -0
  25. package/build/cjs/runner.cjs +141 -0
  26. package/build/cjs/runner.cjs.map +1 -0
  27. package/build/cjs/scenarios/index.cjs +25 -0
  28. package/build/cjs/scenarios/index.cjs.map +1 -0
  29. package/build/cjs/scenarios/install-multiple-deps.cjs +119 -0
  30. package/build/cjs/scenarios/install-multiple-deps.cjs.map +1 -0
  31. package/build/cjs/scenarios/metadata.cjs +121 -0
  32. package/build/cjs/scenarios/metadata.cjs.map +1 -0
  33. package/build/cjs/scenarios/minimum-release-age.cjs +111 -0
  34. package/build/cjs/scenarios/minimum-release-age.cjs.map +1 -0
  35. package/build/cjs/scenarios/search.cjs +200 -0
  36. package/build/cjs/scenarios/search.cjs.map +1 -0
  37. package/build/cjs/scenarios/tarballs.cjs +170 -0
  38. package/build/cjs/scenarios/tarballs.cjs.map +1 -0
  39. package/build/cjs/scenarios/uplink-failure.cjs +141 -0
  40. package/build/cjs/scenarios/uplink-failure.cjs.map +1 -0
  41. package/build/cjs/tests/audit.cjs +60 -0
  42. package/build/cjs/tests/audit.cjs.map +1 -0
  43. package/build/cjs/tests/ci.cjs +83 -0
  44. package/build/cjs/tests/ci.cjs.map +1 -0
  45. package/build/cjs/tests/deprecate.cjs +108 -0
  46. package/build/cjs/tests/deprecate.cjs.map +1 -0
  47. package/build/cjs/tests/dist-tags.cjs +43 -0
  48. package/build/cjs/tests/dist-tags.cjs.map +1 -0
  49. package/build/cjs/tests/index.cjs +49 -0
  50. package/build/cjs/tests/index.cjs.map +1 -0
  51. package/build/cjs/tests/info.cjs +31 -0
  52. package/build/cjs/tests/info.cjs.map +1 -0
  53. package/build/cjs/tests/install.cjs +30 -0
  54. package/build/cjs/tests/install.cjs.map +1 -0
  55. package/build/cjs/tests/login.cjs +126 -0
  56. package/build/cjs/tests/login.cjs.map +1 -0
  57. package/build/cjs/tests/ping.cjs +26 -0
  58. package/build/cjs/tests/ping.cjs.map +1 -0
  59. package/build/cjs/tests/publish.cjs +36 -0
  60. package/build/cjs/tests/publish.cjs.map +1 -0
  61. package/build/cjs/tests/search.cjs +23 -0
  62. package/build/cjs/tests/search.cjs.map +1 -0
  63. package/build/cjs/tests/unpublish.cjs +40 -0
  64. package/build/cjs/tests/unpublish.cjs.map +1 -0
  65. package/build/cjs/utils/e2e-config.cjs +59 -0
  66. package/build/cjs/utils/e2e-config.cjs.map +1 -0
  67. package/build/cjs/utils/http-client.cjs +111 -0
  68. package/build/cjs/utils/http-client.cjs.map +1 -0
  69. package/build/cjs/utils/info.cjs +25 -0
  70. package/build/cjs/utils/info.cjs.map +1 -0
  71. package/build/cjs/utils/mock-uplink.cjs +196 -0
  72. package/build/cjs/utils/mock-uplink.cjs.map +1 -0
  73. package/build/cjs/utils/pending-checks.cjs +21 -0
  74. package/build/cjs/utils/pending-checks.cjs.map +1 -0
  75. package/build/cjs/utils/process.cjs +96 -0
  76. package/build/cjs/utils/process.cjs.map +1 -0
  77. package/build/cjs/utils/project.cjs +55 -0
  78. package/build/cjs/utils/project.cjs.map +1 -0
  79. package/build/cjs/utils/publish.cjs +117 -0
  80. package/build/cjs/utils/publish.cjs.map +1 -0
  81. package/build/cjs/utils/registry-client.cjs +56 -0
  82. package/build/cjs/utils/registry-client.cjs.map +1 -0
  83. package/build/esm/adapters/bun.js +62 -0
  84. package/build/esm/adapters/bun.js.map +1 -0
  85. package/build/esm/adapters/deno.js +59 -0
  86. package/build/esm/adapters/deno.js.map +1 -0
  87. package/build/esm/adapters/index.js +6 -0
  88. package/build/esm/adapters/npm.js +83 -0
  89. package/build/esm/adapters/npm.js.map +1 -0
  90. package/build/esm/adapters/pnpm.js +112 -0
  91. package/build/esm/adapters/pnpm.js.map +1 -0
  92. package/build/esm/adapters/yarn-modern.js +204 -0
  93. package/build/esm/adapters/yarn-modern.js.map +1 -0
  94. package/build/esm/index.js +174 -10
  95. package/build/esm/index.js.map +1 -0
  96. package/build/esm/reporter.js +108 -0
  97. package/build/esm/reporter.js.map +1 -0
  98. package/build/esm/runner.js +138 -0
  99. package/build/esm/runner.js.map +1 -0
  100. package/build/esm/scenarios/index.js +19 -0
  101. package/build/esm/scenarios/index.js.map +1 -0
  102. package/build/esm/scenarios/install-multiple-deps.js +117 -0
  103. package/build/esm/scenarios/install-multiple-deps.js.map +1 -0
  104. package/build/esm/scenarios/metadata.js +119 -0
  105. package/build/esm/scenarios/metadata.js.map +1 -0
  106. package/build/esm/scenarios/minimum-release-age.js +108 -0
  107. package/build/esm/scenarios/minimum-release-age.js.map +1 -0
  108. package/build/esm/scenarios/search.js +198 -0
  109. package/build/esm/scenarios/search.js.map +1 -0
  110. package/build/esm/scenarios/tarballs.js +168 -0
  111. package/build/esm/scenarios/tarballs.js.map +1 -0
  112. package/build/esm/scenarios/uplink-failure.js +139 -0
  113. package/build/esm/scenarios/uplink-failure.js.map +1 -0
  114. package/build/esm/tests/audit.js +57 -0
  115. package/build/esm/tests/audit.js.map +1 -0
  116. package/build/esm/tests/ci.js +80 -0
  117. package/build/esm/tests/ci.js.map +1 -0
  118. package/build/esm/tests/deprecate.js +105 -0
  119. package/build/esm/tests/deprecate.js.map +1 -0
  120. package/build/esm/tests/dist-tags.js +41 -0
  121. package/build/esm/tests/dist-tags.js.map +1 -0
  122. package/build/esm/tests/index.js +37 -0
  123. package/build/esm/tests/index.js.map +1 -0
  124. package/build/esm/tests/info.js +29 -0
  125. package/build/esm/tests/info.js.map +1 -0
  126. package/build/esm/tests/install.js +28 -0
  127. package/build/esm/tests/install.js.map +1 -0
  128. package/build/esm/tests/login.js +122 -0
  129. package/build/esm/tests/login.js.map +1 -0
  130. package/build/esm/tests/ping.js +24 -0
  131. package/build/esm/tests/ping.js.map +1 -0
  132. package/build/esm/tests/publish.js +34 -0
  133. package/build/esm/tests/publish.js.map +1 -0
  134. package/build/esm/tests/search.js +21 -0
  135. package/build/esm/tests/search.js.map +1 -0
  136. package/build/esm/tests/unpublish.js +38 -0
  137. package/build/esm/tests/unpublish.js.map +1 -0
  138. package/build/esm/utils/e2e-config.js +58 -0
  139. package/build/esm/utils/e2e-config.js.map +1 -0
  140. package/build/esm/utils/http-client.js +105 -0
  141. package/build/esm/utils/http-client.js.map +1 -0
  142. package/build/esm/utils/info.js +24 -0
  143. package/build/esm/utils/info.js.map +1 -0
  144. package/build/esm/utils/mock-uplink.js +194 -0
  145. package/build/esm/utils/mock-uplink.js.map +1 -0
  146. package/build/esm/utils/pending-checks.js +21 -0
  147. package/build/esm/utils/pending-checks.js.map +1 -0
  148. package/build/esm/utils/process.js +92 -0
  149. package/build/esm/utils/process.js.map +1 -0
  150. package/build/esm/utils/project.js +50 -0
  151. package/build/esm/utils/project.js.map +1 -0
  152. package/build/esm/utils/publish.js +113 -0
  153. package/build/esm/utils/publish.js.map +1 -0
  154. package/build/esm/utils/registry-client.js +53 -0
  155. package/build/esm/utils/registry-client.js.map +1 -0
  156. package/build/index.d.ts +6 -15
  157. package/build/reporter.d.ts +7 -0
  158. package/build/runner.d.ts +13 -0
  159. package/build/scenarios/index.d.ts +9 -0
  160. package/build/scenarios/install-multiple-deps.d.ts +2 -0
  161. package/build/scenarios/metadata.d.ts +2 -0
  162. package/build/scenarios/minimum-release-age.d.ts +11 -0
  163. package/build/scenarios/search.d.ts +2 -0
  164. package/build/scenarios/tarballs.d.ts +2 -0
  165. package/build/scenarios/uplink-failure.d.ts +2 -0
  166. package/build/tests/audit.d.ts +2 -0
  167. package/build/tests/ci.d.ts +2 -0
  168. package/build/tests/deprecate.d.ts +2 -0
  169. package/build/tests/dist-tags.d.ts +2 -0
  170. package/build/tests/index.d.ts +15 -0
  171. package/build/tests/info.d.ts +2 -0
  172. package/build/tests/install.d.ts +2 -0
  173. package/build/tests/login.d.ts +2 -0
  174. package/build/tests/ping.d.ts +2 -0
  175. package/build/tests/publish.d.ts +2 -0
  176. package/build/tests/search.d.ts +2 -0
  177. package/build/tests/unpublish.d.ts +2 -0
  178. package/build/types.d.ts +91 -0
  179. package/build/utils/e2e-config.d.ts +21 -0
  180. package/build/utils/http-client.d.ts +49 -0
  181. package/build/utils/index.d.ts +4 -0
  182. package/build/utils/info.d.ts +14 -0
  183. package/build/utils/mock-uplink.d.ts +47 -0
  184. package/build/utils/pending-checks.d.ts +12 -0
  185. package/build/utils/process.d.ts +11 -0
  186. package/build/utils/project.d.ts +6 -0
  187. package/build/utils/publish.d.ts +17 -0
  188. package/build/utils/registry-client.d.ts +15 -0
  189. package/package.json +16 -14
  190. package/Cargo.lock +0 -1591
  191. package/Cargo.toml +0 -19
  192. package/build/bin/verdaccio-e2e +0 -0
  193. package/scripts/build-js-package.mjs +0 -25
  194. package/src/e2e_tests/audit.rs +0 -51
  195. package/src/e2e_tests/ci.rs +0 -32
  196. package/src/e2e_tests/deprecate.rs +0 -64
  197. package/src/e2e_tests/dist_tags.rs +0 -56
  198. package/src/e2e_tests/info.rs +0 -43
  199. package/src/e2e_tests/install.rs +0 -27
  200. package/src/e2e_tests/login.rs +0 -64
  201. package/src/e2e_tests/mod.rs +0 -104
  202. package/src/e2e_tests/ping.rs +0 -33
  203. package/src/e2e_tests/publish.rs +0 -68
  204. package/src/e2e_tests/search.rs +0 -14
  205. package/src/e2e_tests/unpublish.rs +0 -37
  206. package/src/main.rs +0 -1411
  207. package/src/scenarios/install_multiple_deps.rs +0 -99
  208. package/src/scenarios/minimum_release_age.rs +0 -45
  209. package/src/scenarios/mod.rs +0 -11
@@ -0,0 +1,108 @@
1
+ import { trace } from "../utils/process.js";
2
+ import { join } from "path";
3
+ import { writeFile } from "fs/promises";
4
+ import assert from "assert";
5
+ //#region src/scenarios/minimum-release-age.ts
6
+ /**
7
+ * Scenario: minimum-release-age (pnpm 11.1+ only)
8
+ *
9
+ * Exercises pnpm's `minimumReleaseAge` cooldown together with
10
+ * `minimumReleaseAgeExclude`. With a 7-day (10080 minute) cooldown, freshly
11
+ * published versions are normally not installable until they age past the
12
+ * threshold — but packages matching the exclude globs bypass the check.
13
+ *
14
+ * minimumReleaseAge: 10080
15
+ * minimumReleaseAgeExclude:
16
+ * - '@verdaccio/*'
17
+ * - 'verdaccio-*'
18
+ *
19
+ * The scenario publishes brand-new (age ~0) packages and asserts:
20
+ * - an excluded scoped package (@verdaccio/*) installs despite the cooldown
21
+ * - an excluded unscoped package (verdaccio-*) installs despite the cooldown
22
+ * - a non-excluded package is blocked by the cooldown (proving it is active)
23
+ *
24
+ * pnpm reads these settings from `pnpm-workspace.yaml`, mirroring the
25
+ * configuration used by the repo root. The cooldown is only enforced from
26
+ * pnpm 11.1.0 onward (11.0.x silently ignores it), so the scenario is gated to
27
+ * that version range — see {@link isPnpm11Plus}.
28
+ */
29
+ var MINIMUM_RELEASE_AGE = 10080;
30
+ var PNPM_WORKSPACE_YAML = [
31
+ `minimumReleaseAge: ${MINIMUM_RELEASE_AGE}`,
32
+ "minimumReleaseAgeExclude:",
33
+ ...["@verdaccio/*", "verdaccio-*"].map((glob) => ` - '${glob}'`),
34
+ ""
35
+ ].join("\n");
36
+ /**
37
+ * True only for pnpm versions that actually enforce minimumReleaseAge.
38
+ *
39
+ * The cooldown is honored in pnpm 11.1.0+, but it is silently ignored in
40
+ * 11.0.0 / 11.0.1 (and the 11.0.0 release candidates) — those versions install
41
+ * brand-new packages regardless of the configured cooldown. We scope this
42
+ * scenario to pnpm 11.1.0 and newer so it doesn't false-fail on that regression.
43
+ */
44
+ function isPnpm11Plus(adapter) {
45
+ if (adapter.type !== "pnpm") return false;
46
+ const match = adapter.name.match(/pnpm@(\d+)\.(\d+)/);
47
+ if (!match) return false;
48
+ const major = parseInt(match[1], 10);
49
+ const minor = parseInt(match[2], 10);
50
+ return major > 11 || major === 11 && minor >= 1;
51
+ }
52
+ async function publishPackage(ctx, pkgName, version) {
53
+ trace("publishing %s@%s", pkgName, version);
54
+ const { tempFolder } = await ctx.adapter.prepareProject(pkgName, version, ctx.registryUrl, ctx.port, ctx.token);
55
+ await ctx.adapter.exec({ cwd: tempFolder }, "publish", ...ctx.adapter.registryArg(ctx.registryUrl));
56
+ trace("published %s@%s", pkgName, version);
57
+ }
58
+ /**
59
+ * Build a consumer project with the minimumReleaseAge cooldown configured in
60
+ * pnpm-workspace.yaml, depending on the given packages.
61
+ */
62
+ async function prepareCooldownConsumer(ctx, consumerName, dependencies) {
63
+ const { tempFolder } = await ctx.adapter.prepareProject(consumerName, "1.0.0", ctx.registryUrl, ctx.port, ctx.token, dependencies);
64
+ await writeFile(join(tempFolder, "pnpm-workspace.yaml"), PNPM_WORKSPACE_YAML);
65
+ trace("prepared cooldown consumer %s at %s", consumerName, tempFolder);
66
+ return tempFolder;
67
+ }
68
+ async function testMinimumReleaseAge(ctx) {
69
+ const id = ctx.runId;
70
+ const excludedScoped = `@verdaccio/release-age-${id}`;
71
+ const excludedUnscoped = `verdaccio-release-age-${id}`;
72
+ const blocked = `e2e-release-age-blocked-${id}`;
73
+ await ctx.subTest("publish fresh packages", async () => {
74
+ await publishPackage(ctx, excludedScoped, "1.0.0");
75
+ await publishPackage(ctx, excludedUnscoped, "1.0.0");
76
+ await publishPackage(ctx, blocked, "1.0.0");
77
+ });
78
+ await ctx.subTest("excluded packages bypass the cooldown", async () => {
79
+ const tempFolder = await prepareCooldownConsumer(ctx, `@verdaccio/consumer-excluded-${id}`, {
80
+ [excludedScoped]: "1.0.0",
81
+ [excludedUnscoped]: "1.0.0"
82
+ });
83
+ await ctx.adapter.exec({ cwd: tempFolder }, "install", ...ctx.adapter.registryArg(ctx.registryUrl));
84
+ trace("excluded packages installed successfully under cooldown");
85
+ });
86
+ await ctx.subTest("non-excluded fresh package is blocked", async () => {
87
+ const tempFolder = await prepareCooldownConsumer(ctx, `e2e-consumer-blocked-${id}`, { [blocked]: "1.0.0" });
88
+ let errorMessage = "";
89
+ try {
90
+ await ctx.adapter.exec({ cwd: tempFolder }, "install", "--frozen-lockfile=false", ...ctx.adapter.registryArg(ctx.registryUrl));
91
+ } catch (err) {
92
+ errorMessage = err instanceof Error ? err.message : String(err);
93
+ trace("install correctly rejected by cooldown: %s", errorMessage.split("\n")[0]);
94
+ }
95
+ assert.ok(errorMessage, `Expected install of fresh non-excluded package "${blocked}" to be blocked by minimumReleaseAge (${MINIMUM_RELEASE_AGE} min), but it succeeded`);
96
+ assert.ok(/minimumReleaseAge|NO_MATURE_MATCHING_VERSION/i.test(errorMessage), `Install of "${blocked}" failed for an unexpected reason (expected a minimumReleaseAge cooldown error):\n${errorMessage}`);
97
+ });
98
+ }
99
+ var minimumReleaseAgeScenario = {
100
+ name: "scenario:minimum-release-age",
101
+ requires: ["publish", "install"],
102
+ appliesTo: isPnpm11Plus,
103
+ run: testMinimumReleaseAge
104
+ };
105
+ //#endregion
106
+ export { isPnpm11Plus, minimumReleaseAgeScenario };
107
+
108
+ //# sourceMappingURL=minimum-release-age.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"minimum-release-age.js","names":[],"sources":["../../../src/scenarios/minimum-release-age.ts"],"sourcesContent":["import assert from 'assert';\nimport { writeFile } from 'fs/promises';\nimport { join } from 'path';\n\nimport { PackageManagerAdapter, TestContext, TestDefinition } from '../types';\nimport { trace } from '../utils/process';\n\n/**\n * Scenario: minimum-release-age (pnpm 11.1+ only)\n *\n * Exercises pnpm's `minimumReleaseAge` cooldown together with\n * `minimumReleaseAgeExclude`. With a 7-day (10080 minute) cooldown, freshly\n * published versions are normally not installable until they age past the\n * threshold — but packages matching the exclude globs bypass the check.\n *\n * minimumReleaseAge: 10080\n * minimumReleaseAgeExclude:\n * - '@verdaccio/*'\n * - 'verdaccio-*'\n *\n * The scenario publishes brand-new (age ~0) packages and asserts:\n * - an excluded scoped package (@verdaccio/*) installs despite the cooldown\n * - an excluded unscoped package (verdaccio-*) installs despite the cooldown\n * - a non-excluded package is blocked by the cooldown (proving it is active)\n *\n * pnpm reads these settings from `pnpm-workspace.yaml`, mirroring the\n * configuration used by the repo root. The cooldown is only enforced from\n * pnpm 11.1.0 onward (11.0.x silently ignores it), so the scenario is gated to\n * that version range — see {@link isPnpm11Plus}.\n */\n\nconst MINIMUM_RELEASE_AGE = 10080; // 7 days, in minutes\nconst MINIMUM_RELEASE_AGE_EXCLUDE = ['@verdaccio/*', 'verdaccio-*'];\n\nconst PNPM_WORKSPACE_YAML = [\n `minimumReleaseAge: ${MINIMUM_RELEASE_AGE}`,\n 'minimumReleaseAgeExclude:',\n ...MINIMUM_RELEASE_AGE_EXCLUDE.map((glob) => ` - '${glob}'`),\n '',\n].join('\\n');\n\n/**\n * True only for pnpm versions that actually enforce minimumReleaseAge.\n *\n * The cooldown is honored in pnpm 11.1.0+, but it is silently ignored in\n * 11.0.0 / 11.0.1 (and the 11.0.0 release candidates) — those versions install\n * brand-new packages regardless of the configured cooldown. We scope this\n * scenario to pnpm 11.1.0 and newer so it doesn't false-fail on that regression.\n */\nexport function isPnpm11Plus(adapter: PackageManagerAdapter): boolean {\n if (adapter.type !== 'pnpm') {\n return false;\n }\n const match = adapter.name.match(/pnpm@(\\d+)\\.(\\d+)/);\n if (!match) {\n return false;\n }\n const major = parseInt(match[1], 10);\n const minor = parseInt(match[2], 10);\n return major > 11 || (major === 11 && minor >= 1);\n}\n\nasync function publishPackage(ctx: TestContext, pkgName: string, version: string): Promise<void> {\n trace('publishing %s@%s', pkgName, version);\n const { tempFolder } = await ctx.adapter.prepareProject(\n pkgName,\n version,\n ctx.registryUrl,\n ctx.port,\n ctx.token\n );\n await ctx.adapter.exec(\n { cwd: tempFolder },\n 'publish',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n trace('published %s@%s', pkgName, version);\n}\n\n/**\n * Build a consumer project with the minimumReleaseAge cooldown configured in\n * pnpm-workspace.yaml, depending on the given packages.\n */\nasync function prepareCooldownConsumer(\n ctx: TestContext,\n consumerName: string,\n dependencies: Record<string, string>\n): Promise<string> {\n const { tempFolder } = await ctx.adapter.prepareProject(\n consumerName,\n '1.0.0',\n ctx.registryUrl,\n ctx.port,\n ctx.token,\n dependencies\n );\n await writeFile(join(tempFolder, 'pnpm-workspace.yaml'), PNPM_WORKSPACE_YAML);\n trace('prepared cooldown consumer %s at %s', consumerName, tempFolder);\n return tempFolder;\n}\n\nasync function testMinimumReleaseAge(ctx: TestContext): Promise<void> {\n const id = ctx.runId;\n\n const excludedScoped = `@verdaccio/release-age-${id}`;\n const excludedUnscoped = `verdaccio-release-age-${id}`;\n const blocked = `e2e-release-age-blocked-${id}`;\n\n // --- Phase 1: Publish brand-new (age ~0) packages ---\n await ctx.subTest('publish fresh packages', async () => {\n await publishPackage(ctx, excludedScoped, '1.0.0');\n await publishPackage(ctx, excludedUnscoped, '1.0.0');\n await publishPackage(ctx, blocked, '1.0.0');\n });\n\n // --- Phase 2: Excluded packages install despite the cooldown ---\n await ctx.subTest('excluded packages bypass the cooldown', async () => {\n const tempFolder = await prepareCooldownConsumer(ctx, `@verdaccio/consumer-excluded-${id}`, {\n [excludedScoped]: '1.0.0',\n [excludedUnscoped]: '1.0.0',\n });\n\n // Should succeed: both deps match the exclude globs.\n await ctx.adapter.exec(\n { cwd: tempFolder },\n 'install',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n\n trace('excluded packages installed successfully under cooldown');\n });\n\n // --- Phase 3: Non-excluded fresh package is blocked by the cooldown ---\n await ctx.subTest('non-excluded fresh package is blocked', async () => {\n const tempFolder = await prepareCooldownConsumer(ctx, `e2e-consumer-blocked-${id}`, {\n [blocked]: '1.0.0',\n });\n\n let errorMessage = '';\n try {\n await ctx.adapter.exec(\n { cwd: tempFolder },\n 'install',\n '--frozen-lockfile=false',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n } catch (err) {\n errorMessage = err instanceof Error ? err.message : String(err);\n trace('install correctly rejected by cooldown: %s', errorMessage.split('\\n')[0]);\n }\n\n assert.ok(\n errorMessage,\n `Expected install of fresh non-excluded package \"${blocked}\" to be blocked by ` +\n `minimumReleaseAge (${MINIMUM_RELEASE_AGE} min), but it succeeded`\n );\n // pnpm reports ERR_PNPM_NO_MATURE_MATCHING_VERSION / \"minimumReleaseAge\" when a\n // version is too new. Assert the failure is the cooldown, not an unrelated error.\n assert.ok(\n /minimumReleaseAge|NO_MATURE_MATCHING_VERSION/i.test(errorMessage),\n `Install of \"${blocked}\" failed for an unexpected reason (expected a minimumReleaseAge ` +\n `cooldown error):\\n${errorMessage}`\n );\n });\n}\n\nexport const minimumReleaseAgeScenario: TestDefinition = {\n name: 'scenario:minimum-release-age',\n requires: ['publish', 'install'],\n appliesTo: isPnpm11Plus,\n run: testMinimumReleaseAge,\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,IAAM,sBAAsB;AAG5B,IAAM,sBAAsB;CAC1B,sBAAsB;CACtB;CACA,GAAG,CALgC,gBAAgB,aAKhD,CAAA,CAA4B,KAAK,SAAS,QAAQ,KAAK,EAAE;CAC5D;AACF,CAAC,CAAC,KAAK,IAAI;;;;;;;;;AAUX,SAAgB,aAAa,SAAyC;CACpE,IAAI,QAAQ,SAAS,QACnB,OAAO;CAET,MAAM,QAAQ,QAAQ,KAAK,MAAM,mBAAmB;CACpD,IAAI,CAAC,OACH,OAAO;CAET,MAAM,QAAQ,SAAS,MAAM,IAAI,EAAE;CACnC,MAAM,QAAQ,SAAS,MAAM,IAAI,EAAE;CACnC,OAAO,QAAQ,MAAO,UAAU,MAAM,SAAS;AACjD;AAEA,eAAe,eAAe,KAAkB,SAAiB,SAAgC;CAC/F,MAAM,oBAAoB,SAAS,OAAO;CAC1C,MAAM,EAAE,eAAe,MAAM,IAAI,QAAQ,eACvC,SACA,SACA,IAAI,aACJ,IAAI,MACJ,IAAI,KACN;CACA,MAAM,IAAI,QAAQ,KAChB,EAAE,KAAK,WAAW,GAClB,WACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;CACA,MAAM,mBAAmB,SAAS,OAAO;AAC3C;;;;;AAMA,eAAe,wBACb,KACA,cACA,cACiB;CACjB,MAAM,EAAE,eAAe,MAAM,IAAI,QAAQ,eACvC,cACA,SACA,IAAI,aACJ,IAAI,MACJ,IAAI,OACJ,YACF;CACA,MAAM,UAAU,KAAK,YAAY,qBAAqB,GAAG,mBAAmB;CAC5E,MAAM,uCAAuC,cAAc,UAAU;CACrE,OAAO;AACT;AAEA,eAAe,sBAAsB,KAAiC;CACpE,MAAM,KAAK,IAAI;CAEf,MAAM,iBAAiB,0BAA0B;CACjD,MAAM,mBAAmB,yBAAyB;CAClD,MAAM,UAAU,2BAA2B;CAG3C,MAAM,IAAI,QAAQ,0BAA0B,YAAY;EACtD,MAAM,eAAe,KAAK,gBAAgB,OAAO;EACjD,MAAM,eAAe,KAAK,kBAAkB,OAAO;EACnD,MAAM,eAAe,KAAK,SAAS,OAAO;CAC5C,CAAC;CAGD,MAAM,IAAI,QAAQ,yCAAyC,YAAY;EACrE,MAAM,aAAa,MAAM,wBAAwB,KAAK,gCAAgC,MAAM;IACzF,iBAAiB;IACjB,mBAAmB;EACtB,CAAC;EAGD,MAAM,IAAI,QAAQ,KAChB,EAAE,KAAK,WAAW,GAClB,WACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;EAEA,MAAM,yDAAyD;CACjE,CAAC;CAGD,MAAM,IAAI,QAAQ,yCAAyC,YAAY;EACrE,MAAM,aAAa,MAAM,wBAAwB,KAAK,wBAAwB,MAAM,GACjF,UAAU,QACb,CAAC;EAED,IAAI,eAAe;EACnB,IAAI;GACF,MAAM,IAAI,QAAQ,KAChB,EAAE,KAAK,WAAW,GAClB,WACA,2BACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;EACF,SAAS,KAAK;GACZ,eAAe,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC9D,MAAM,8CAA8C,aAAa,MAAM,IAAI,CAAC,CAAC,EAAE;EACjF;EAEA,OAAO,GACL,cACA,mDAAmD,QAAQ,wCACnC,oBAAoB,wBAC9C;EAGA,OAAO,GACL,gDAAgD,KAAK,YAAY,GACjE,eAAe,QAAQ,oFACA,cACzB;CACF,CAAC;AACH;AAEA,IAAa,4BAA4C;CACvD,MAAM;CACN,UAAU,CAAC,WAAW,SAAS;CAC/B,WAAW;CACX,KAAK;AACP"}
@@ -0,0 +1,198 @@
1
+ import { trace } from "../utils/process.js";
2
+ import { MockUplink } from "../utils/mock-uplink.js";
3
+ import { pendingContractChecksEnabled } from "../utils/pending-checks.js";
4
+ import { publishLocalPackage } from "../utils/publish.js";
5
+ import assert from "assert";
6
+ //#region src/scenarios/search.ts
7
+ /**
8
+ * Scenario: search
9
+ *
10
+ * HTTP-level battery for `GET /-/v1/search`, pinning the registry.npmjs.org
11
+ * contract (api-documentation `search.yaml` + npm CLI 12 behavior):
12
+ *
13
+ * - result shape npm CLI depends on: `package.name/version/date` and
14
+ * `maintainers` as an array (format-search-stream maps it without a guard)
15
+ * - `total` = total number of matches, not the size of the returned page
16
+ * - `from`/`size` pagination walks the result set without gaps or overlaps
17
+ * - merged local + uplink results are paginated exactly once (the uplink
18
+ * already applies `from` like npmjs does — re-slicing yields empty pages)
19
+ * - missing `text` → 400 (spec: ERR_TEXT_MISSING), not an empty 200
20
+ * - `time` is an ISO 8601 date-time
21
+ * - oversized `size` is clamped, not an error
22
+ * - a real `npm search --json` on top of the raw HTTP checks
23
+ *
24
+ * The uplink sub-test drives the shared MockUplink (which implements
25
+ * /-/v1/search the way npmjs does) and is gated on E2E_UPLINK_PORT plus the
26
+ * registry being started with the config from --print-config.
27
+ *
28
+ * Protocol-level and package-manager-agnostic, so it is gated to the npm
29
+ * adapter to run once per suite.
30
+ */
31
+ var LOCAL_COUNT = 8;
32
+ var UPLINK_COUNT = 6;
33
+ /**
34
+ * Contract checks that are red against every current registry: real `total`,
35
+ * 400 without `text`, ISO `time`, single pagination of merged local+uplink
36
+ * results, and `package.version` on results. Off by default so CI stays
37
+ * green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that the
38
+ * default once the fixes land in verdaccio).
39
+ */
40
+ var PENDING_CONTRACT_CHECKS_ENABLED = pendingContractChecksEnabled("search");
41
+ function uplinkPort() {
42
+ return parseInt(process.env.E2E_UPLINK_PORT || "", 10);
43
+ }
44
+ async function searchV1(registryUrl, query) {
45
+ const url = new URL("/-/v1/search", registryUrl);
46
+ for (const [key, value] of Object.entries(query)) if (value !== void 0) url.searchParams.set(key, String(value));
47
+ trace("GET %s", url);
48
+ const started = Date.now();
49
+ const response = await fetch(url);
50
+ let body;
51
+ try {
52
+ body = JSON.parse(await response.text());
53
+ } catch {
54
+ body = void 0;
55
+ }
56
+ trace("GET %s → %d in %dms | total=%s objects=%d [%s]", url, response.status, Date.now() - started, body?.total, body?.objects?.length ?? -1, (body?.objects ?? []).map((o) => o?.package?.name).join(", "));
57
+ return {
58
+ status: response.status,
59
+ body
60
+ };
61
+ }
62
+ /**
63
+ * Page through the result set for `text` and assert the pages are disjoint,
64
+ * respect `size`, and together cover exactly `expectedNames`.
65
+ */
66
+ async function assertPaginationCovers(registryUrl, text, size, expectedNames) {
67
+ const seen = /* @__PURE__ */ new Set();
68
+ for (let from = 0; from < expectedNames.length; from += size) {
69
+ const remaining = expectedNames.length - from;
70
+ const { status, body } = await searchV1(registryUrl, {
71
+ text,
72
+ size,
73
+ from
74
+ });
75
+ assert.strictEqual(status, 200, `Expected 200 for from=${from}`);
76
+ const names = body.objects.map((o) => o.package.name);
77
+ trace("page from=%d size=%d → %d results: %s", from, size, names.length, names.join(", "));
78
+ assert.strictEqual(names.length, Math.min(size, remaining), `Expected ${Math.min(size, remaining)} results at from=${from} (got ${names.length}: ${names.join(", ") || "empty page"})`);
79
+ for (const name of names) {
80
+ assert.ok(!seen.has(name), `Duplicate result "${name}" across pages (from=${from})`);
81
+ seen.add(name);
82
+ }
83
+ }
84
+ const missing = expectedNames.filter((name) => !seen.has(name));
85
+ assert.strictEqual(missing.length, 0, `Pagination never returned: ${missing.join(", ")}`);
86
+ }
87
+ async function testSearch(ctx) {
88
+ const id = ctx.runId;
89
+ const localPrefix = `e2e-srchloc-${id}`;
90
+ const localNames = Array.from({ length: LOCAL_COUNT }, (_, i) => `${localPrefix}-p${i + 1}`);
91
+ const failed = [];
92
+ const check = async (label, fn) => {
93
+ try {
94
+ await ctx.subTest(label, fn);
95
+ } catch (err) {
96
+ trace("check FAILED: %s — %s", label, err instanceof Error ? err.message : err);
97
+ failed.push(`${label} — ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ };
100
+ await ctx.subTest("publish searchable packages", async () => {
101
+ for (const name of localNames) await publishLocalPackage(ctx, name, "1.0.0");
102
+ });
103
+ await check("results have the shape npm CLI depends on", async () => {
104
+ const { status, body } = await searchV1(ctx.registryUrl, {
105
+ text: localPrefix,
106
+ size: 250
107
+ });
108
+ assert.strictEqual(status, 200, "Expected 200 from /-/v1/search");
109
+ assert.ok(Array.isArray(body.objects), "Expected an \"objects\" array");
110
+ trace("first result object: %j", body.objects[0]);
111
+ assert.strictEqual(body.objects.length, LOCAL_COUNT, `Expected ${LOCAL_COUNT} results for ${localPrefix}, got ${body.objects.length}`);
112
+ for (const object of body.objects) {
113
+ assert.ok(object.package?.name?.startsWith(localPrefix), "Result outside the query prefix");
114
+ if (PENDING_CONTRACT_CHECKS_ENABLED) assert.ok(object.package.version, `Expected a version on ${object.package.name}`);
115
+ assert.ok(Array.isArray(object.package.maintainers), `maintainers must be an array on ${object.package.name}`);
116
+ }
117
+ });
118
+ if (PENDING_CONTRACT_CHECKS_ENABLED) await check("total is the number of matches, not the page size", async () => {
119
+ const { status, body } = await searchV1(ctx.registryUrl, {
120
+ text: localPrefix,
121
+ size: 3
122
+ });
123
+ assert.strictEqual(status, 200, "Expected 200 from /-/v1/search");
124
+ assert.strictEqual(body.objects.length, 3, "Expected the page to honor size=3");
125
+ assert.strictEqual(body.total, LOCAL_COUNT, `Expected total=${LOCAL_COUNT} (all matches), got ${body.total}`);
126
+ });
127
+ await check("pagination walks local results without gaps", async () => {
128
+ await assertPaginationCovers(ctx.registryUrl, localPrefix, 3, localNames);
129
+ });
130
+ if (PENDING_CONTRACT_CHECKS_ENABLED) {
131
+ await check("missing text returns 400 ERR_TEXT_MISSING", async () => {
132
+ const { status, body } = await searchV1(ctx.registryUrl, {});
133
+ assert.strictEqual(status, 400, `Expected 400 without "text", got ${status}`);
134
+ assert.ok(body?.error, "Expected an \"error\" field in the 400 body");
135
+ });
136
+ await check("time is an ISO 8601 date-time", async () => {
137
+ const { body } = await searchV1(ctx.registryUrl, {
138
+ text: localPrefix,
139
+ size: 1
140
+ });
141
+ assert.ok(body.time, "Expected a \"time\" field");
142
+ assert.match(String(body.time), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, `Expected ISO 8601 time, got "${body.time}"`);
143
+ });
144
+ }
145
+ await check("oversized size is clamped, not an error", async () => {
146
+ const { status, body } = await searchV1(ctx.registryUrl, {
147
+ text: localPrefix,
148
+ size: 1e5
149
+ });
150
+ assert.strictEqual(status, 200, "Expected 200 with an oversized size");
151
+ assert.ok(body.objects.length <= 250, "Expected the npmjs size cap (250) to apply");
152
+ });
153
+ await check("npm search finds the published packages", async () => {
154
+ const { tempFolder } = await ctx.adapter.prepareProject(`search-battery-client-${id}`, "1.0.0", ctx.registryUrl, ctx.port, ctx.token);
155
+ const resp = await ctx.adapter.exec({ cwd: tempFolder }, "search", localPrefix, "--json", ...ctx.adapter.registryArg(ctx.registryUrl));
156
+ const results = JSON.parse(resp.stdout);
157
+ for (const name of localNames) assert.ok(results.find((item) => item.name === name), `Expected ${name} in npm search output`);
158
+ });
159
+ if (PENDING_CONTRACT_CHECKS_ENABLED && Number.isFinite(uplinkPort())) {
160
+ const mock = new MockUplink(uplinkPort());
161
+ const remoteNames = Array.from({ length: UPLINK_COUNT }, (_, i) => `${localPrefix}-r${i + 1}`);
162
+ for (const name of remoteNames) mock.addSearchResult(name);
163
+ await mock.start();
164
+ try {
165
+ await check("merged local+uplink results are paginated once", async () => {
166
+ const allNames = [...localNames, ...remoteNames];
167
+ const { status, body } = await searchV1(ctx.registryUrl, {
168
+ text: localPrefix,
169
+ size: 250
170
+ });
171
+ assert.strictEqual(status, 200, "Expected 200 with the uplink up");
172
+ assert.strictEqual(body.objects.length, allNames.length, `Expected ${allNames.length} merged results, got ${body.objects.length}`);
173
+ await assertPaginationCovers(ctx.registryUrl, localPrefix, 5, allNames);
174
+ });
175
+ await check("merged total counts local and uplink matches", async () => {
176
+ const { body } = await searchV1(ctx.registryUrl, {
177
+ text: localPrefix,
178
+ size: 5
179
+ });
180
+ assert.strictEqual(body.total, 14, `Expected total=14, got ${body.total}`);
181
+ });
182
+ } finally {
183
+ await mock.stop();
184
+ }
185
+ } else trace("E2E_UPLINK_PORT not set — skipping merged local+uplink pagination sub-tests");
186
+ if (failed.length > 0) throw new Error(`${failed.length} search contract check(s) failed:\n - ${failed.join("\n - ")}`);
187
+ }
188
+ var searchScenario = {
189
+ name: "scenario:search",
190
+ requires: ["publish", "search"],
191
+ timeout: 9e4,
192
+ appliesTo: (adapter) => adapter.type === "npm",
193
+ run: testSearch
194
+ };
195
+ //#endregion
196
+ export { searchScenario };
197
+
198
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","names":[],"sources":["../../../src/scenarios/search.ts"],"sourcesContent":["import assert from 'assert';\n\nimport { TestContext, TestDefinition } from '../types';\nimport { MockUplink } from '../utils/mock-uplink';\nimport { pendingContractChecksEnabled } from '../utils/pending-checks';\nimport { trace } from '../utils/process';\nimport { publishLocalPackage } from '../utils/publish';\n\n/**\n * Scenario: search\n *\n * HTTP-level battery for `GET /-/v1/search`, pinning the registry.npmjs.org\n * contract (api-documentation `search.yaml` + npm CLI 12 behavior):\n *\n * - result shape npm CLI depends on: `package.name/version/date` and\n * `maintainers` as an array (format-search-stream maps it without a guard)\n * - `total` = total number of matches, not the size of the returned page\n * - `from`/`size` pagination walks the result set without gaps or overlaps\n * - merged local + uplink results are paginated exactly once (the uplink\n * already applies `from` like npmjs does — re-slicing yields empty pages)\n * - missing `text` → 400 (spec: ERR_TEXT_MISSING), not an empty 200\n * - `time` is an ISO 8601 date-time\n * - oversized `size` is clamped, not an error\n * - a real `npm search --json` on top of the raw HTTP checks\n *\n * The uplink sub-test drives the shared MockUplink (which implements\n * /-/v1/search the way npmjs does) and is gated on E2E_UPLINK_PORT plus the\n * registry being started with the config from --print-config.\n *\n * Protocol-level and package-manager-agnostic, so it is gated to the npm\n * adapter to run once per suite.\n */\n\nconst LOCAL_COUNT = 8;\nconst UPLINK_COUNT = 6;\n\n/**\n * Contract checks that are red against every current registry: real `total`,\n * 400 without `text`, ISO `time`, single pagination of merged local+uplink\n * results, and `package.version` on results. Off by default so CI stays\n * green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that the\n * default once the fixes land in verdaccio).\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = pendingContractChecksEnabled('search');\n\nfunction uplinkPort(): number {\n return parseInt(process.env.E2E_UPLINK_PORT || '', 10);\n}\n\ntype SearchResponse = {\n status: number;\n body?: any;\n};\n\nasync function searchV1(\n registryUrl: string,\n query: Record<string, string | number | undefined>\n): Promise<SearchResponse> {\n const url = new URL('/-/v1/search', registryUrl);\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined) {\n url.searchParams.set(key, String(value));\n }\n }\n trace('GET %s', url);\n const started = Date.now();\n const response = await fetch(url);\n let body: any;\n try {\n body = JSON.parse(await response.text());\n } catch {\n body = undefined;\n }\n trace(\n 'GET %s → %d in %dms | total=%s objects=%d [%s]',\n url,\n response.status,\n Date.now() - started,\n body?.total,\n body?.objects?.length ?? -1,\n (body?.objects ?? []).map((o: any) => o?.package?.name).join(', ')\n );\n return { status: response.status, body };\n}\n\n/**\n * Page through the result set for `text` and assert the pages are disjoint,\n * respect `size`, and together cover exactly `expectedNames`.\n */\nasync function assertPaginationCovers(\n registryUrl: string,\n text: string,\n size: number,\n expectedNames: string[]\n): Promise<void> {\n const seen = new Set<string>();\n for (let from = 0; from < expectedNames.length; from += size) {\n const remaining = expectedNames.length - from;\n const { status, body } = await searchV1(registryUrl, { text, size, from });\n assert.strictEqual(status, 200, `Expected 200 for from=${from}`);\n const names = body.objects.map((o: any) => o.package.name);\n trace('page from=%d size=%d → %d results: %s', from, size, names.length, names.join(', '));\n assert.strictEqual(\n names.length,\n Math.min(size, remaining),\n `Expected ${Math.min(size, remaining)} results at from=${from} (got ${names.length}: ${names.join(', ') || 'empty page'})`\n );\n for (const name of names) {\n assert.ok(!seen.has(name), `Duplicate result \"${name}\" across pages (from=${from})`);\n seen.add(name);\n }\n }\n const missing = expectedNames.filter((name) => !seen.has(name));\n assert.strictEqual(missing.length, 0, `Pagination never returned: ${missing.join(', ')}`);\n}\n\nasync function testSearch(ctx: TestContext): Promise<void> {\n const id = ctx.runId;\n const localPrefix = `e2e-srchloc-${id}`;\n const localNames = Array.from({ length: LOCAL_COUNT }, (_, i) => `${localPrefix}-p${i + 1}`);\n\n // The battery's checks are independent findings against the search contract:\n // run them all and fail at the end, instead of aborting on the first one\n // (ctx.subTest rethrows by design for dependent steps).\n const failed: string[] = [];\n const check = async (label: string, fn: () => Promise<void>): Promise<void> => {\n try {\n await ctx.subTest(label, fn);\n } catch (err) {\n trace('check FAILED: %s — %s', label, err instanceof Error ? err.message : err);\n failed.push(`${label} — ${err instanceof Error ? err.message : String(err)}`);\n }\n };\n\n // Publishing is a real dependency — without it nothing below means anything.\n await ctx.subTest('publish searchable packages', async () => {\n for (const name of localNames) {\n await publishLocalPackage(ctx, name, '1.0.0');\n }\n });\n\n await check('results have the shape npm CLI depends on', async () => {\n const { status, body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 250 });\n assert.strictEqual(status, 200, 'Expected 200 from /-/v1/search');\n assert.ok(Array.isArray(body.objects), 'Expected an \"objects\" array');\n trace('first result object: %j', body.objects[0]);\n assert.strictEqual(\n body.objects.length,\n LOCAL_COUNT,\n `Expected ${LOCAL_COUNT} results for ${localPrefix}, got ${body.objects.length}`\n );\n for (const object of body.objects) {\n assert.ok(object.package?.name?.startsWith(localPrefix), 'Result outside the query prefix');\n if (PENDING_CONTRACT_CHECKS_ENABLED) {\n // npm CLI reads package.version, but verdaccio 6 omits it in search\n // results — pinned with the rest of the pending contract checks.\n assert.ok(object.package.version, `Expected a version on ${object.package.name}`);\n }\n assert.ok(\n Array.isArray(object.package.maintainers),\n // npm CLI's format-search-stream maps maintainers without a guard\n `maintainers must be an array on ${object.package.name}`\n );\n }\n });\n\n if (PENDING_CONTRACT_CHECKS_ENABLED) {\n // S-3: total must count all matches, not the returned page.\n await check('total is the number of matches, not the page size', async () => {\n const { status, body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 3 });\n assert.strictEqual(status, 200, 'Expected 200 from /-/v1/search');\n assert.strictEqual(body.objects.length, 3, 'Expected the page to honor size=3');\n assert.strictEqual(\n body.total,\n LOCAL_COUNT,\n `Expected total=${LOCAL_COUNT} (all matches), got ${body.total}`\n );\n });\n }\n\n await check('pagination walks local results without gaps', async () => {\n await assertPaginationCovers(ctx.registryUrl, localPrefix, 3, localNames);\n });\n\n if (PENDING_CONTRACT_CHECKS_ENABLED) {\n // S-4: the spec documents 400 ERR_TEXT_MISSING when text is absent.\n await check('missing text returns 400 ERR_TEXT_MISSING', async () => {\n const { status, body } = await searchV1(ctx.registryUrl, {});\n assert.strictEqual(status, 400, `Expected 400 without \"text\", got ${status}`);\n assert.ok(body?.error, 'Expected an \"error\" field in the 400 body');\n });\n\n // S-5: the spec documents an ISO 8601 date-time, not toUTCString().\n await check('time is an ISO 8601 date-time', async () => {\n const { body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 1 });\n assert.ok(body.time, 'Expected a \"time\" field');\n assert.match(\n String(body.time),\n /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/,\n `Expected ISO 8601 time, got \"${body.time}\"`\n );\n });\n }\n\n await check('oversized size is clamped, not an error', async () => {\n const { status, body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 100000 });\n assert.strictEqual(status, 200, 'Expected 200 with an oversized size');\n assert.ok(body.objects.length <= 250, 'Expected the npmjs size cap (250) to apply');\n });\n\n await check('npm search finds the published packages', async () => {\n const { tempFolder } = await ctx.adapter.prepareProject(\n `search-battery-client-${id}`,\n '1.0.0',\n ctx.registryUrl,\n ctx.port,\n ctx.token\n );\n const resp = await ctx.adapter.exec(\n { cwd: tempFolder },\n 'search',\n localPrefix,\n '--json',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n const results = JSON.parse(resp.stdout);\n for (const name of localNames) {\n assert.ok(\n results.find((item: any) => item.name === name),\n `Expected ${name} in npm search output`\n );\n }\n });\n\n // S-1: merged local + uplink pagination — the case where double slicing\n // shows up: the uplink already returns the offset page (like npmjs), so\n // slicing again in the registry produces empty or shifted pages.\n if (PENDING_CONTRACT_CHECKS_ENABLED && Number.isFinite(uplinkPort())) {\n const mock = new MockUplink(uplinkPort());\n const remoteNames = Array.from({ length: UPLINK_COUNT }, (_, i) => `${localPrefix}-r${i + 1}`);\n for (const name of remoteNames) {\n mock.addSearchResult(name);\n }\n await mock.start();\n try {\n await check('merged local+uplink results are paginated once', async () => {\n const allNames = [...localNames, ...remoteNames];\n const { status, body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 250 });\n assert.strictEqual(status, 200, 'Expected 200 with the uplink up');\n assert.strictEqual(\n body.objects.length,\n allNames.length,\n `Expected ${allNames.length} merged results, got ${body.objects.length}`\n );\n await assertPaginationCovers(ctx.registryUrl, localPrefix, 5, allNames);\n });\n\n await check('merged total counts local and uplink matches', async () => {\n const { body } = await searchV1(ctx.registryUrl, { text: localPrefix, size: 5 });\n assert.strictEqual(\n body.total,\n LOCAL_COUNT + UPLINK_COUNT,\n `Expected total=${LOCAL_COUNT + UPLINK_COUNT}, got ${body.total}`\n );\n });\n } finally {\n await mock.stop();\n }\n } else {\n trace('E2E_UPLINK_PORT not set — skipping merged local+uplink pagination sub-tests');\n }\n\n if (failed.length > 0) {\n throw new Error(\n `${failed.length} search contract check(s) failed:\\n - ${failed.join('\\n - ')}`\n );\n }\n}\n\nexport const searchScenario: TestDefinition = {\n name: 'scenario:search',\n requires: ['publish', 'search'],\n timeout: 90_000,\n // Protocol-level battery — run once per suite via the npm adapter.\n appliesTo: (adapter) => adapter.type === 'npm',\n run: testSearch,\n};\n"],"mappings":"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"}
@@ -0,0 +1,168 @@
1
+ import { trace } from "../utils/process.js";
2
+ import { downloadTarball, fetchPackument, packumentUrl } from "../utils/http-client.js";
3
+ import { pendingContractChecksEnabled } from "../utils/pending-checks.js";
4
+ import { join } from "path";
5
+ import { writeFile } from "fs/promises";
6
+ import assert from "assert";
7
+ import { randomBytes } from "crypto";
8
+ //#region src/scenarios/tarballs.ts
9
+ /**
10
+ * Scenario: tarballs
11
+ *
12
+ * HTTP-level battery for the tarball endpoints. Publishes a large (~30 MB by
13
+ * default, incompressible) package through the real package manager, then
14
+ * exercises the raw registry protocol with fetch():
15
+ *
16
+ * - full download with Content-Length + shasum/integrity verification
17
+ * - client aborts mid-download (repeatedly) without breaking the registry
18
+ * - concurrent downloads of the same large tarball
19
+ * - 404 behavior for missing packages / versions / mismatched filenames
20
+ * - scoped packages via the npm `%2f`-encoded URL form
21
+ * - end-to-end install of the large package through the package manager
22
+ *
23
+ * The registry must accept large publishes — Verdaccio's default
24
+ * `max_body_size` is 10mb, so the harness config needs e.g. `max_body_size: 100mb`
25
+ * (see `--print-config`). The payload size can be tuned with
26
+ * E2E_LARGE_TARBALL_MB (default 30).
27
+ *
28
+ * Protocol-level and package-manager-agnostic, so it is gated to the npm
29
+ * adapter to run once per suite.
30
+ */
31
+ var LARGE_MB = parseInt(process.env.E2E_LARGE_TARBALL_MB || "30", 10);
32
+ var CONCURRENT_DOWNLOADS = 8;
33
+ /**
34
+ * Contract checks that are red against current registries: tarballs are
35
+ * streamed chunked with no Content-Length, and compression() re-gzips the
36
+ * already-compressed .tgz for gzip-accepting clients. Off by default so CI
37
+ * stays green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that
38
+ * the default once the fixes land in a published verdaccio).
39
+ */
40
+ var PENDING_CONTRACT_CHECKS_ENABLED = pendingContractChecksEnabled("tarballs");
41
+ async function publishWithPayload(ctx, pkgName, version, payloadBytes) {
42
+ const { tempFolder } = await ctx.adapter.prepareProject(pkgName, version, ctx.registryUrl, ctx.port, ctx.token);
43
+ if (payloadBytes) await writeFile(join(tempFolder, "payload.bin"), randomBytes(payloadBytes));
44
+ await ctx.adapter.exec({ cwd: tempFolder }, "publish", ...ctx.adapter.registryArg(ctx.registryUrl));
45
+ trace("published %s@%s (payload: %d bytes)", pkgName, version, payloadBytes ?? 0);
46
+ }
47
+ async function getDist(ctx, pkgName, version) {
48
+ const { status, body } = await fetchPackument(ctx.registryUrl, pkgName);
49
+ assert.strictEqual(status, 200, `Expected 200 fetching packument for ${pkgName}`);
50
+ const dist = body?.versions?.[version]?.dist;
51
+ assert.ok(dist?.tarball, `Expected dist.tarball for ${pkgName}@${version}`);
52
+ return dist;
53
+ }
54
+ async function testTarballs(ctx) {
55
+ const id = ctx.runId;
56
+ const largePkg = `e2e-tarball-large-${id}`;
57
+ const largeBytes = LARGE_MB * 1024 * 1024;
58
+ let largeTarballUrl = "";
59
+ let largeSha1 = "";
60
+ await ctx.subTest(`publish large package (~${LARGE_MB} MB)`, async () => {
61
+ await publishWithPayload(ctx, largePkg, "1.0.0", largeBytes);
62
+ });
63
+ await ctx.subTest("download large tarball and verify integrity", async () => {
64
+ const dist = await getDist(ctx, largePkg, "1.0.0");
65
+ largeTarballUrl = dist.tarball;
66
+ const download = await downloadTarball(dist.tarball);
67
+ trace("large tarball %s → %d | bytes=%d content-length=%s content-encoding=%s transfer-encoding=%s", dist.tarball, download.status, download.bytes, download.headers.get("content-length"), download.headers.get("content-encoding"), download.headers.get("transfer-encoding"));
68
+ trace("advertised dist: shasum=%s integrity=%s", dist.shasum, dist.integrity);
69
+ assert.strictEqual(download.status, 200, `Expected 200 downloading ${dist.tarball}`);
70
+ assert.ok(download.bytes >= largeBytes, `Expected tarball >= ${largeBytes} bytes (incompressible payload), got ${download.bytes}`);
71
+ const contentLength = download.headers.get("content-length");
72
+ if (contentLength) assert.strictEqual(parseInt(contentLength, 10), download.bytes, `Content-Length (${contentLength}) does not match received bytes (${download.bytes})`);
73
+ assert.ok(dist.shasum, "Expected dist.shasum on the published version");
74
+ assert.strictEqual(download.sha1, dist.shasum, "shasum mismatch on downloaded tarball");
75
+ assert.ok(dist.integrity, "Expected dist.integrity on the published version");
76
+ assert.strictEqual(download.integrity, dist.integrity, "integrity mismatch on downloaded tarball");
77
+ largeSha1 = download.sha1;
78
+ });
79
+ if (PENDING_CONTRACT_CHECKS_ENABLED) {
80
+ await ctx.subTest("tarball response advertises Content-Length", async () => {
81
+ const response = await fetch(largeTarballUrl, { headers: { "accept-encoding": "identity" } });
82
+ await response.body?.cancel();
83
+ const contentLength = response.headers.get("content-length");
84
+ assert.ok(contentLength, "Expected Content-Length on an identity-encoded tarball response");
85
+ assert.ok(parseInt(contentLength, 10) >= largeBytes, `Content-Length (${contentLength}) smaller than the payload (${largeBytes})`);
86
+ });
87
+ await ctx.subTest("tarball is not re-compressed for gzip-accepting clients", async () => {
88
+ const response = await fetch(largeTarballUrl, { headers: { "accept-encoding": "gzip" } });
89
+ await response.body?.cancel();
90
+ assert.strictEqual(response.headers.get("content-encoding"), null, "Registry re-compresses the already-gzipped .tgz (wasted CPU on every download)");
91
+ });
92
+ }
93
+ await ctx.subTest("abort mid-download is handled gracefully", async () => {
94
+ const abortThresholds = [
95
+ 1,
96
+ Math.floor(largeBytes * .25),
97
+ Math.floor(largeBytes * .5)
98
+ ];
99
+ for (const threshold of abortThresholds) {
100
+ const controller = new AbortController();
101
+ let aborted = false;
102
+ try {
103
+ await downloadTarball(largeTarballUrl, {
104
+ signal: controller.signal,
105
+ onChunk: (bytes) => {
106
+ if (bytes >= threshold) controller.abort();
107
+ }
108
+ });
109
+ } catch (err) {
110
+ aborted = true;
111
+ trace("download aborted at >=%d bytes as expected: %s", threshold, err instanceof Error ? err.message : err);
112
+ }
113
+ assert.ok(aborted, `Expected download to abort mid-stream (threshold ${threshold} bytes)`);
114
+ }
115
+ const ping = await fetch(`${ctx.registryUrl}/-/ping`);
116
+ trace("ping after aborts → %d", ping.status);
117
+ assert.strictEqual(ping.status, 200, "Registry did not respond to ping after aborts");
118
+ const download = await downloadTarball(largeTarballUrl);
119
+ trace("full download after aborts → %d bytes=%d", download.status, download.bytes);
120
+ assert.strictEqual(download.status, 200, "Full download after aborts failed");
121
+ assert.strictEqual(download.sha1, largeSha1, "Tarball corrupted after aborted downloads");
122
+ });
123
+ await ctx.subTest(`${CONCURRENT_DOWNLOADS} concurrent downloads stay consistent`, async () => {
124
+ const downloads = await Promise.all(Array.from({ length: CONCURRENT_DOWNLOADS }, () => downloadTarball(largeTarballUrl)));
125
+ for (const download of downloads) {
126
+ assert.strictEqual(download.status, 200, "Concurrent download failed");
127
+ assert.strictEqual(download.sha1, largeSha1, "Concurrent download corrupted");
128
+ }
129
+ });
130
+ await ctx.subTest("missing tarballs return 404", async () => {
131
+ const cases = [
132
+ `${packumentUrl(ctx.registryUrl, largePkg)}/-/${largePkg}-9.9.9.tgz`,
133
+ `${packumentUrl(ctx.registryUrl, `does-not-exist-${id}`)}/-/does-not-exist-${id}-1.0.0.tgz`,
134
+ `${packumentUrl(ctx.registryUrl, largePkg)}/-/some-other-file-1.0.0.tgz`
135
+ ];
136
+ for (const url of cases) {
137
+ const response = await fetch(url);
138
+ const responseBody = await response.text();
139
+ trace("404 case %s → %d | body=%s", url, response.status, responseBody.slice(0, 200));
140
+ assert.strictEqual(response.status, 404, `Expected 404 for ${url}, got ${response.status}`);
141
+ }
142
+ });
143
+ await ctx.subTest("scoped package tarball via %2f-encoded URL", async () => {
144
+ const scopedPkg = `@verdaccio/e2e-tarball-${id}`;
145
+ await publishWithPayload(ctx, scopedPkg, "1.0.0");
146
+ const dist = await getDist(ctx, scopedPkg, "1.0.0");
147
+ const download = await downloadTarball(dist.tarball);
148
+ assert.strictEqual(download.status, 200, `Expected 200 downloading ${dist.tarball}`);
149
+ assert.ok(download.bytes > 0, "Scoped tarball is empty");
150
+ assert.ok(dist.shasum, "Expected dist.shasum on the scoped version");
151
+ assert.strictEqual(download.sha1, dist.shasum, "Scoped tarball shasum mismatch");
152
+ });
153
+ await ctx.subTest("package manager installs the large package", async () => {
154
+ const { tempFolder } = await ctx.adapter.prepareProject(`e2e-tarball-consumer-${id}`, "1.0.0", ctx.registryUrl, ctx.port, ctx.token, { [largePkg]: "1.0.0" });
155
+ await ctx.adapter.exec({ cwd: tempFolder }, "install", ...ctx.adapter.registryArg(ctx.registryUrl));
156
+ });
157
+ }
158
+ var tarballsScenario = {
159
+ name: "scenario:tarballs",
160
+ requires: ["publish", "install"],
161
+ appliesTo: (adapter) => adapter.type === "npm",
162
+ timeout: 18e4,
163
+ run: testTarballs
164
+ };
165
+ //#endregion
166
+ export { tarballsScenario };
167
+
168
+ //# sourceMappingURL=tarballs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tarballs.js","names":[],"sources":["../../../src/scenarios/tarballs.ts"],"sourcesContent":["import assert from 'assert';\nimport { randomBytes } from 'crypto';\nimport { writeFile } from 'fs/promises';\nimport { join } from 'path';\n\nimport { TestContext, TestDefinition } from '../types';\nimport { downloadTarball, fetchPackument, packumentUrl } from '../utils/http-client';\nimport { pendingContractChecksEnabled } from '../utils/pending-checks';\nimport { trace } from '../utils/process';\n\n/**\n * Scenario: tarballs\n *\n * HTTP-level battery for the tarball endpoints. Publishes a large (~30 MB by\n * default, incompressible) package through the real package manager, then\n * exercises the raw registry protocol with fetch():\n *\n * - full download with Content-Length + shasum/integrity verification\n * - client aborts mid-download (repeatedly) without breaking the registry\n * - concurrent downloads of the same large tarball\n * - 404 behavior for missing packages / versions / mismatched filenames\n * - scoped packages via the npm `%2f`-encoded URL form\n * - end-to-end install of the large package through the package manager\n *\n * The registry must accept large publishes — Verdaccio's default\n * `max_body_size` is 10mb, so the harness config needs e.g. `max_body_size: 100mb`\n * (see `--print-config`). The payload size can be tuned with\n * E2E_LARGE_TARBALL_MB (default 30).\n *\n * Protocol-level and package-manager-agnostic, so it is gated to the npm\n * adapter to run once per suite.\n */\n\nconst LARGE_MB = parseInt(process.env.E2E_LARGE_TARBALL_MB || '30', 10);\nconst CONCURRENT_DOWNLOADS = 8;\n\n/**\n * Contract checks that are red against current registries: tarballs are\n * streamed chunked with no Content-Length, and compression() re-gzips the\n * already-compressed .tgz for gzip-accepting clients. Off by default so CI\n * stays green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that\n * the default once the fixes land in a published verdaccio).\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = pendingContractChecksEnabled('tarballs');\n\nasync function publishWithPayload(\n ctx: TestContext,\n pkgName: string,\n version: string,\n payloadBytes?: number\n): Promise<void> {\n const { tempFolder } = await ctx.adapter.prepareProject(\n pkgName,\n version,\n ctx.registryUrl,\n ctx.port,\n ctx.token\n );\n if (payloadBytes) {\n // Random bytes are incompressible, so the gzipped tarball keeps ~this size.\n await writeFile(join(tempFolder, 'payload.bin'), randomBytes(payloadBytes));\n }\n await ctx.adapter.exec(\n { cwd: tempFolder },\n 'publish',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n trace('published %s@%s (payload: %d bytes)', pkgName, version, payloadBytes ?? 0);\n}\n\nasync function getDist(\n ctx: TestContext,\n pkgName: string,\n version: string\n): Promise<{ tarball: string; shasum?: string; integrity?: string }> {\n const { status, body } = await fetchPackument(ctx.registryUrl, pkgName);\n assert.strictEqual(status, 200, `Expected 200 fetching packument for ${pkgName}`);\n const dist = body?.versions?.[version]?.dist;\n assert.ok(dist?.tarball, `Expected dist.tarball for ${pkgName}@${version}`);\n return dist;\n}\n\nasync function testTarballs(ctx: TestContext): Promise<void> {\n const id = ctx.runId;\n const largePkg = `e2e-tarball-large-${id}`;\n const largeBytes = LARGE_MB * 1024 * 1024;\n let largeTarballUrl = '';\n let largeSha1 = '';\n\n await ctx.subTest(`publish large package (~${LARGE_MB} MB)`, async () => {\n await publishWithPayload(ctx, largePkg, '1.0.0', largeBytes);\n });\n\n await ctx.subTest('download large tarball and verify integrity', async () => {\n const dist = await getDist(ctx, largePkg, '1.0.0');\n largeTarballUrl = dist.tarball;\n\n const download = await downloadTarball(dist.tarball);\n trace(\n 'large tarball %s → %d | bytes=%d content-length=%s content-encoding=%s transfer-encoding=%s',\n dist.tarball,\n download.status,\n download.bytes,\n download.headers.get('content-length'),\n download.headers.get('content-encoding'),\n download.headers.get('transfer-encoding')\n );\n trace('advertised dist: shasum=%s integrity=%s', dist.shasum, dist.integrity);\n assert.strictEqual(download.status, 200, `Expected 200 downloading ${dist.tarball}`);\n assert.ok(\n download.bytes >= largeBytes,\n `Expected tarball >= ${largeBytes} bytes (incompressible payload), got ${download.bytes}`\n );\n // When Content-Length is present it must match; its *presence* is pinned\n // by the gated contract check below (Verdaccio streams chunked today).\n const contentLength = download.headers.get('content-length');\n if (contentLength) {\n assert.strictEqual(\n parseInt(contentLength, 10),\n download.bytes,\n `Content-Length (${contentLength}) does not match received bytes (${download.bytes})`\n );\n }\n // A locally published version must always advertise its integrity — the\n // absence of these fields IS a bug, so no conditional soft-skips.\n assert.ok(dist.shasum, 'Expected dist.shasum on the published version');\n assert.strictEqual(download.sha1, dist.shasum, 'shasum mismatch on downloaded tarball');\n assert.ok(dist.integrity, 'Expected dist.integrity on the published version');\n assert.strictEqual(\n download.integrity,\n dist.integrity,\n 'integrity mismatch on downloaded tarball'\n );\n largeSha1 = download.sha1;\n });\n\n if (PENDING_CONTRACT_CHECKS_ENABLED) {\n await ctx.subTest('tarball response advertises Content-Length', async () => {\n const response = await fetch(largeTarballUrl, {\n headers: { 'accept-encoding': 'identity' },\n });\n await response.body?.cancel();\n const contentLength = response.headers.get('content-length');\n assert.ok(contentLength, 'Expected Content-Length on an identity-encoded tarball response');\n assert.ok(\n parseInt(contentLength as string, 10) >= largeBytes,\n `Content-Length (${contentLength}) smaller than the payload (${largeBytes})`\n );\n });\n\n await ctx.subTest('tarball is not re-compressed for gzip-accepting clients', async () => {\n const response = await fetch(largeTarballUrl, { headers: { 'accept-encoding': 'gzip' } });\n await response.body?.cancel();\n assert.strictEqual(\n response.headers.get('content-encoding'),\n null,\n 'Registry re-compresses the already-gzipped .tgz (wasted CPU on every download)'\n );\n });\n }\n\n await ctx.subTest('abort mid-download is handled gracefully', async () => {\n // Abort at different depths into the stream: the first chunk exercises the\n // early-teardown path, the deeper thresholds exercise aborts while the\n // registry is mid-flight with backpressure on a large body.\n const abortThresholds = [1, Math.floor(largeBytes * 0.25), Math.floor(largeBytes * 0.5)];\n for (const threshold of abortThresholds) {\n const controller = new AbortController();\n let aborted = false;\n try {\n await downloadTarball(largeTarballUrl, {\n signal: controller.signal,\n onChunk: (bytes) => {\n if (bytes >= threshold) {\n controller.abort();\n }\n },\n });\n } catch (err) {\n aborted = true;\n trace(\n 'download aborted at >=%d bytes as expected: %s',\n threshold,\n err instanceof Error ? err.message : err\n );\n }\n assert.ok(aborted, `Expected download to abort mid-stream (threshold ${threshold} bytes)`);\n }\n\n // The registry must survive the aborts: ping + a full download still work.\n const ping = await fetch(`${ctx.registryUrl}/-/ping`);\n trace('ping after aborts → %d', ping.status);\n assert.strictEqual(ping.status, 200, 'Registry did not respond to ping after aborts');\n const download = await downloadTarball(largeTarballUrl);\n trace('full download after aborts → %d bytes=%d', download.status, download.bytes);\n assert.strictEqual(download.status, 200, 'Full download after aborts failed');\n assert.strictEqual(download.sha1, largeSha1, 'Tarball corrupted after aborted downloads');\n });\n\n await ctx.subTest(`${CONCURRENT_DOWNLOADS} concurrent downloads stay consistent`, async () => {\n const downloads = await Promise.all(\n Array.from({ length: CONCURRENT_DOWNLOADS }, () => downloadTarball(largeTarballUrl))\n );\n for (const download of downloads) {\n assert.strictEqual(download.status, 200, 'Concurrent download failed');\n assert.strictEqual(download.sha1, largeSha1, 'Concurrent download corrupted');\n }\n });\n\n await ctx.subTest('missing tarballs return 404', async () => {\n const cases = [\n // existing package, nonexistent version\n `${packumentUrl(ctx.registryUrl, largePkg)}/-/${largePkg}-9.9.9.tgz`,\n // nonexistent package\n `${packumentUrl(ctx.registryUrl, `does-not-exist-${id}`)}/-/does-not-exist-${id}-1.0.0.tgz`,\n // existing package, filename that doesn't belong to it\n `${packumentUrl(ctx.registryUrl, largePkg)}/-/some-other-file-1.0.0.tgz`,\n ];\n for (const url of cases) {\n const response = await fetch(url);\n // drain the body so sockets are released\n const responseBody = await response.text();\n trace('404 case %s → %d | body=%s', url, response.status, responseBody.slice(0, 200));\n assert.strictEqual(response.status, 404, `Expected 404 for ${url}, got ${response.status}`);\n }\n });\n\n await ctx.subTest('scoped package tarball via %2f-encoded URL', async () => {\n const scopedPkg = `@verdaccio/e2e-tarball-${id}`;\n await publishWithPayload(ctx, scopedPkg, '1.0.0');\n const dist = await getDist(ctx, scopedPkg, '1.0.0');\n const download = await downloadTarball(dist.tarball);\n assert.strictEqual(download.status, 200, `Expected 200 downloading ${dist.tarball}`);\n assert.ok(download.bytes > 0, 'Scoped tarball is empty');\n assert.ok(dist.shasum, 'Expected dist.shasum on the scoped version');\n assert.strictEqual(download.sha1, dist.shasum, 'Scoped tarball shasum mismatch');\n });\n\n await ctx.subTest('package manager installs the large package', async () => {\n const { tempFolder } = await ctx.adapter.prepareProject(\n `e2e-tarball-consumer-${id}`,\n '1.0.0',\n ctx.registryUrl,\n ctx.port,\n ctx.token,\n { [largePkg]: '1.0.0' }\n );\n await ctx.adapter.exec(\n { cwd: tempFolder },\n 'install',\n ...ctx.adapter.registryArg(ctx.registryUrl)\n );\n });\n}\n\nexport const tarballsScenario: TestDefinition = {\n name: 'scenario:tarballs',\n requires: ['publish', 'install'],\n // Protocol-level battery — run once per suite via the npm adapter.\n appliesTo: (adapter) => adapter.type === 'npm',\n timeout: 180_000,\n run: testTarballs,\n};\n"],"mappings":"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"}