@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.
- package/bin/e2e-cli.js +6 -26
- package/build/adapters/adapter.d.ts +1 -0
- package/build/adapters/bun.d.ts +2 -0
- package/build/adapters/deno.d.ts +2 -0
- package/build/adapters/index.d.ts +5 -0
- package/build/adapters/npm.d.ts +2 -0
- package/build/adapters/pnpm.d.ts +2 -0
- package/build/adapters/yarn-modern.d.ts +2 -0
- package/build/cjs/_virtual/_rolldown/runtime.cjs +23 -0
- package/build/cjs/adapters/bun.cjs +64 -0
- package/build/cjs/adapters/bun.cjs.map +1 -0
- package/build/cjs/adapters/deno.cjs +61 -0
- package/build/cjs/adapters/deno.cjs.map +1 -0
- package/build/cjs/adapters/index.cjs +10 -0
- package/build/cjs/adapters/npm.cjs +85 -0
- package/build/cjs/adapters/npm.cjs.map +1 -0
- package/build/cjs/adapters/pnpm.cjs +114 -0
- package/build/cjs/adapters/pnpm.cjs.map +1 -0
- package/build/cjs/adapters/yarn-modern.cjs +207 -0
- package/build/cjs/adapters/yarn-modern.cjs.map +1 -0
- package/build/cjs/index.cjs +186 -11
- package/build/cjs/index.cjs.map +1 -0
- package/build/cjs/reporter.cjs +113 -0
- package/build/cjs/reporter.cjs.map +1 -0
- package/build/cjs/runner.cjs +141 -0
- package/build/cjs/runner.cjs.map +1 -0
- package/build/cjs/scenarios/index.cjs +25 -0
- package/build/cjs/scenarios/index.cjs.map +1 -0
- package/build/cjs/scenarios/install-multiple-deps.cjs +119 -0
- package/build/cjs/scenarios/install-multiple-deps.cjs.map +1 -0
- package/build/cjs/scenarios/metadata.cjs +121 -0
- package/build/cjs/scenarios/metadata.cjs.map +1 -0
- package/build/cjs/scenarios/minimum-release-age.cjs +111 -0
- package/build/cjs/scenarios/minimum-release-age.cjs.map +1 -0
- package/build/cjs/scenarios/search.cjs +200 -0
- package/build/cjs/scenarios/search.cjs.map +1 -0
- package/build/cjs/scenarios/tarballs.cjs +170 -0
- package/build/cjs/scenarios/tarballs.cjs.map +1 -0
- package/build/cjs/scenarios/uplink-failure.cjs +141 -0
- package/build/cjs/scenarios/uplink-failure.cjs.map +1 -0
- package/build/cjs/tests/audit.cjs +60 -0
- package/build/cjs/tests/audit.cjs.map +1 -0
- package/build/cjs/tests/ci.cjs +83 -0
- package/build/cjs/tests/ci.cjs.map +1 -0
- package/build/cjs/tests/deprecate.cjs +108 -0
- package/build/cjs/tests/deprecate.cjs.map +1 -0
- package/build/cjs/tests/dist-tags.cjs +43 -0
- package/build/cjs/tests/dist-tags.cjs.map +1 -0
- package/build/cjs/tests/index.cjs +49 -0
- package/build/cjs/tests/index.cjs.map +1 -0
- package/build/cjs/tests/info.cjs +31 -0
- package/build/cjs/tests/info.cjs.map +1 -0
- package/build/cjs/tests/install.cjs +30 -0
- package/build/cjs/tests/install.cjs.map +1 -0
- package/build/cjs/tests/login.cjs +126 -0
- package/build/cjs/tests/login.cjs.map +1 -0
- package/build/cjs/tests/ping.cjs +26 -0
- package/build/cjs/tests/ping.cjs.map +1 -0
- package/build/cjs/tests/publish.cjs +36 -0
- package/build/cjs/tests/publish.cjs.map +1 -0
- package/build/cjs/tests/search.cjs +23 -0
- package/build/cjs/tests/search.cjs.map +1 -0
- package/build/cjs/tests/unpublish.cjs +40 -0
- package/build/cjs/tests/unpublish.cjs.map +1 -0
- package/build/cjs/utils/e2e-config.cjs +59 -0
- package/build/cjs/utils/e2e-config.cjs.map +1 -0
- package/build/cjs/utils/http-client.cjs +111 -0
- package/build/cjs/utils/http-client.cjs.map +1 -0
- package/build/cjs/utils/info.cjs +25 -0
- package/build/cjs/utils/info.cjs.map +1 -0
- package/build/cjs/utils/mock-uplink.cjs +196 -0
- package/build/cjs/utils/mock-uplink.cjs.map +1 -0
- package/build/cjs/utils/pending-checks.cjs +21 -0
- package/build/cjs/utils/pending-checks.cjs.map +1 -0
- package/build/cjs/utils/process.cjs +96 -0
- package/build/cjs/utils/process.cjs.map +1 -0
- package/build/cjs/utils/project.cjs +55 -0
- package/build/cjs/utils/project.cjs.map +1 -0
- package/build/cjs/utils/publish.cjs +117 -0
- package/build/cjs/utils/publish.cjs.map +1 -0
- package/build/cjs/utils/registry-client.cjs +56 -0
- package/build/cjs/utils/registry-client.cjs.map +1 -0
- package/build/esm/adapters/bun.js +62 -0
- package/build/esm/adapters/bun.js.map +1 -0
- package/build/esm/adapters/deno.js +59 -0
- package/build/esm/adapters/deno.js.map +1 -0
- package/build/esm/adapters/index.js +6 -0
- package/build/esm/adapters/npm.js +83 -0
- package/build/esm/adapters/npm.js.map +1 -0
- package/build/esm/adapters/pnpm.js +112 -0
- package/build/esm/adapters/pnpm.js.map +1 -0
- package/build/esm/adapters/yarn-modern.js +204 -0
- package/build/esm/adapters/yarn-modern.js.map +1 -0
- package/build/esm/index.js +174 -10
- package/build/esm/index.js.map +1 -0
- package/build/esm/reporter.js +108 -0
- package/build/esm/reporter.js.map +1 -0
- package/build/esm/runner.js +138 -0
- package/build/esm/runner.js.map +1 -0
- package/build/esm/scenarios/index.js +19 -0
- package/build/esm/scenarios/index.js.map +1 -0
- package/build/esm/scenarios/install-multiple-deps.js +117 -0
- package/build/esm/scenarios/install-multiple-deps.js.map +1 -0
- package/build/esm/scenarios/metadata.js +119 -0
- package/build/esm/scenarios/metadata.js.map +1 -0
- package/build/esm/scenarios/minimum-release-age.js +108 -0
- package/build/esm/scenarios/minimum-release-age.js.map +1 -0
- package/build/esm/scenarios/search.js +198 -0
- package/build/esm/scenarios/search.js.map +1 -0
- package/build/esm/scenarios/tarballs.js +168 -0
- package/build/esm/scenarios/tarballs.js.map +1 -0
- package/build/esm/scenarios/uplink-failure.js +139 -0
- package/build/esm/scenarios/uplink-failure.js.map +1 -0
- package/build/esm/tests/audit.js +57 -0
- package/build/esm/tests/audit.js.map +1 -0
- package/build/esm/tests/ci.js +80 -0
- package/build/esm/tests/ci.js.map +1 -0
- package/build/esm/tests/deprecate.js +105 -0
- package/build/esm/tests/deprecate.js.map +1 -0
- package/build/esm/tests/dist-tags.js +41 -0
- package/build/esm/tests/dist-tags.js.map +1 -0
- package/build/esm/tests/index.js +37 -0
- package/build/esm/tests/index.js.map +1 -0
- package/build/esm/tests/info.js +29 -0
- package/build/esm/tests/info.js.map +1 -0
- package/build/esm/tests/install.js +28 -0
- package/build/esm/tests/install.js.map +1 -0
- package/build/esm/tests/login.js +122 -0
- package/build/esm/tests/login.js.map +1 -0
- package/build/esm/tests/ping.js +24 -0
- package/build/esm/tests/ping.js.map +1 -0
- package/build/esm/tests/publish.js +34 -0
- package/build/esm/tests/publish.js.map +1 -0
- package/build/esm/tests/search.js +21 -0
- package/build/esm/tests/search.js.map +1 -0
- package/build/esm/tests/unpublish.js +38 -0
- package/build/esm/tests/unpublish.js.map +1 -0
- package/build/esm/utils/e2e-config.js +58 -0
- package/build/esm/utils/e2e-config.js.map +1 -0
- package/build/esm/utils/http-client.js +105 -0
- package/build/esm/utils/http-client.js.map +1 -0
- package/build/esm/utils/info.js +24 -0
- package/build/esm/utils/info.js.map +1 -0
- package/build/esm/utils/mock-uplink.js +194 -0
- package/build/esm/utils/mock-uplink.js.map +1 -0
- package/build/esm/utils/pending-checks.js +21 -0
- package/build/esm/utils/pending-checks.js.map +1 -0
- package/build/esm/utils/process.js +92 -0
- package/build/esm/utils/process.js.map +1 -0
- package/build/esm/utils/project.js +50 -0
- package/build/esm/utils/project.js.map +1 -0
- package/build/esm/utils/publish.js +113 -0
- package/build/esm/utils/publish.js.map +1 -0
- package/build/esm/utils/registry-client.js +53 -0
- package/build/esm/utils/registry-client.js.map +1 -0
- package/build/index.d.ts +6 -15
- package/build/reporter.d.ts +7 -0
- package/build/runner.d.ts +13 -0
- package/build/scenarios/index.d.ts +9 -0
- package/build/scenarios/install-multiple-deps.d.ts +2 -0
- package/build/scenarios/metadata.d.ts +2 -0
- package/build/scenarios/minimum-release-age.d.ts +11 -0
- package/build/scenarios/search.d.ts +2 -0
- package/build/scenarios/tarballs.d.ts +2 -0
- package/build/scenarios/uplink-failure.d.ts +2 -0
- package/build/tests/audit.d.ts +2 -0
- package/build/tests/ci.d.ts +2 -0
- package/build/tests/deprecate.d.ts +2 -0
- package/build/tests/dist-tags.d.ts +2 -0
- package/build/tests/index.d.ts +15 -0
- package/build/tests/info.d.ts +2 -0
- package/build/tests/install.d.ts +2 -0
- package/build/tests/login.d.ts +2 -0
- package/build/tests/ping.d.ts +2 -0
- package/build/tests/publish.d.ts +2 -0
- package/build/tests/search.d.ts +2 -0
- package/build/tests/unpublish.d.ts +2 -0
- package/build/types.d.ts +91 -0
- package/build/utils/e2e-config.d.ts +21 -0
- package/build/utils/http-client.d.ts +49 -0
- package/build/utils/index.d.ts +4 -0
- package/build/utils/info.d.ts +14 -0
- package/build/utils/mock-uplink.d.ts +47 -0
- package/build/utils/pending-checks.d.ts +12 -0
- package/build/utils/process.d.ts +11 -0
- package/build/utils/project.d.ts +6 -0
- package/build/utils/publish.d.ts +17 -0
- package/build/utils/registry-client.d.ts +15 -0
- package/package.json +16 -14
- package/Cargo.lock +0 -1591
- package/Cargo.toml +0 -19
- package/build/bin/verdaccio-e2e +0 -0
- package/scripts/build-js-package.mjs +0 -25
- package/src/e2e_tests/audit.rs +0 -51
- package/src/e2e_tests/ci.rs +0 -32
- package/src/e2e_tests/deprecate.rs +0 -64
- package/src/e2e_tests/dist_tags.rs +0 -56
- package/src/e2e_tests/info.rs +0 -43
- package/src/e2e_tests/install.rs +0 -27
- package/src/e2e_tests/login.rs +0 -64
- package/src/e2e_tests/mod.rs +0 -104
- package/src/e2e_tests/ping.rs +0 -33
- package/src/e2e_tests/publish.rs +0 -68
- package/src/e2e_tests/search.rs +0 -14
- package/src/e2e_tests/unpublish.rs +0 -37
- package/src/main.rs +0 -1411
- package/src/scenarios/install_multiple_deps.rs +0 -99
- package/src/scenarios/minimum_release_age.rs +0 -45
- 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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"}
|