@verdaccio/e2e-cli 3.1.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/build/cjs/scenarios/search.cjs +66 -2
- package/build/cjs/scenarios/search.cjs.map +1 -1
- package/build/cjs/scenarios/tarballs.cjs +23 -0
- package/build/cjs/scenarios/tarballs.cjs.map +1 -1
- package/build/cjs/utils/pending-checks.cjs +21 -0
- package/build/cjs/utils/pending-checks.cjs.map +1 -0
- package/build/esm/scenarios/search.js +66 -2
- package/build/esm/scenarios/search.js.map +1 -1
- package/build/esm/scenarios/tarballs.js +23 -0
- package/build/esm/scenarios/tarballs.js.map +1 -1
- package/build/esm/utils/pending-checks.js +21 -0
- package/build/esm/utils/pending-checks.js.map +1 -0
- package/build/utils/pending-checks.d.ts +12 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
|
|
2
2
|
const require_process = require("../utils/process.cjs");
|
|
3
|
-
require("../utils/mock-uplink.cjs");
|
|
3
|
+
const require_mock_uplink = require("../utils/mock-uplink.cjs");
|
|
4
|
+
const require_pending_checks = require("../utils/pending-checks.cjs");
|
|
4
5
|
const require_publish = require("../utils/publish.cjs");
|
|
5
6
|
let assert = require("assert");
|
|
6
7
|
assert = require_runtime.__toESM(assert, 1);
|
|
@@ -30,6 +31,18 @@ assert = require_runtime.__toESM(assert, 1);
|
|
|
30
31
|
* adapter to run once per suite.
|
|
31
32
|
*/
|
|
32
33
|
var LOCAL_COUNT = 8;
|
|
34
|
+
var UPLINK_COUNT = 6;
|
|
35
|
+
/**
|
|
36
|
+
* Contract checks that are red against every current registry: real `total`,
|
|
37
|
+
* 400 without `text`, ISO `time`, single pagination of merged local+uplink
|
|
38
|
+
* results, and `package.version` on results. Off by default so CI stays
|
|
39
|
+
* green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that the
|
|
40
|
+
* default once the fixes land in verdaccio).
|
|
41
|
+
*/
|
|
42
|
+
var PENDING_CONTRACT_CHECKS_ENABLED = require_pending_checks.pendingContractChecksEnabled("search");
|
|
43
|
+
function uplinkPort() {
|
|
44
|
+
return parseInt(process.env.E2E_UPLINK_PORT || "", 10);
|
|
45
|
+
}
|
|
33
46
|
async function searchV1(registryUrl, query) {
|
|
34
47
|
const url = new URL("/-/v1/search", registryUrl);
|
|
35
48
|
for (const [key, value] of Object.entries(query)) if (value !== void 0) url.searchParams.set(key, String(value));
|
|
@@ -100,12 +113,37 @@ async function testSearch(ctx) {
|
|
|
100
113
|
assert.default.strictEqual(body.objects.length, LOCAL_COUNT, `Expected ${LOCAL_COUNT} results for ${localPrefix}, got ${body.objects.length}`);
|
|
101
114
|
for (const object of body.objects) {
|
|
102
115
|
assert.default.ok(object.package?.name?.startsWith(localPrefix), "Result outside the query prefix");
|
|
116
|
+
if (PENDING_CONTRACT_CHECKS_ENABLED) assert.default.ok(object.package.version, `Expected a version on ${object.package.name}`);
|
|
103
117
|
assert.default.ok(Array.isArray(object.package.maintainers), `maintainers must be an array on ${object.package.name}`);
|
|
104
118
|
}
|
|
105
119
|
});
|
|
120
|
+
if (PENDING_CONTRACT_CHECKS_ENABLED) await check("total is the number of matches, not the page size", async () => {
|
|
121
|
+
const { status, body } = await searchV1(ctx.registryUrl, {
|
|
122
|
+
text: localPrefix,
|
|
123
|
+
size: 3
|
|
124
|
+
});
|
|
125
|
+
assert.default.strictEqual(status, 200, "Expected 200 from /-/v1/search");
|
|
126
|
+
assert.default.strictEqual(body.objects.length, 3, "Expected the page to honor size=3");
|
|
127
|
+
assert.default.strictEqual(body.total, LOCAL_COUNT, `Expected total=${LOCAL_COUNT} (all matches), got ${body.total}`);
|
|
128
|
+
});
|
|
106
129
|
await check("pagination walks local results without gaps", async () => {
|
|
107
130
|
await assertPaginationCovers(ctx.registryUrl, localPrefix, 3, localNames);
|
|
108
131
|
});
|
|
132
|
+
if (PENDING_CONTRACT_CHECKS_ENABLED) {
|
|
133
|
+
await check("missing text returns 400 ERR_TEXT_MISSING", async () => {
|
|
134
|
+
const { status, body } = await searchV1(ctx.registryUrl, {});
|
|
135
|
+
assert.default.strictEqual(status, 400, `Expected 400 without "text", got ${status}`);
|
|
136
|
+
assert.default.ok(body?.error, "Expected an \"error\" field in the 400 body");
|
|
137
|
+
});
|
|
138
|
+
await check("time is an ISO 8601 date-time", async () => {
|
|
139
|
+
const { body } = await searchV1(ctx.registryUrl, {
|
|
140
|
+
text: localPrefix,
|
|
141
|
+
size: 1
|
|
142
|
+
});
|
|
143
|
+
assert.default.ok(body.time, "Expected a \"time\" field");
|
|
144
|
+
assert.default.match(String(body.time), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, `Expected ISO 8601 time, got "${body.time}"`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
109
147
|
await check("oversized size is clamped, not an error", async () => {
|
|
110
148
|
const { status, body } = await searchV1(ctx.registryUrl, {
|
|
111
149
|
text: localPrefix,
|
|
@@ -120,7 +158,33 @@ async function testSearch(ctx) {
|
|
|
120
158
|
const results = JSON.parse(resp.stdout);
|
|
121
159
|
for (const name of localNames) assert.default.ok(results.find((item) => item.name === name), `Expected ${name} in npm search output`);
|
|
122
160
|
});
|
|
123
|
-
|
|
161
|
+
if (PENDING_CONTRACT_CHECKS_ENABLED && Number.isFinite(uplinkPort())) {
|
|
162
|
+
const mock = new require_mock_uplink.MockUplink(uplinkPort());
|
|
163
|
+
const remoteNames = Array.from({ length: UPLINK_COUNT }, (_, i) => `${localPrefix}-r${i + 1}`);
|
|
164
|
+
for (const name of remoteNames) mock.addSearchResult(name);
|
|
165
|
+
await mock.start();
|
|
166
|
+
try {
|
|
167
|
+
await check("merged local+uplink results are paginated once", async () => {
|
|
168
|
+
const allNames = [...localNames, ...remoteNames];
|
|
169
|
+
const { status, body } = await searchV1(ctx.registryUrl, {
|
|
170
|
+
text: localPrefix,
|
|
171
|
+
size: 250
|
|
172
|
+
});
|
|
173
|
+
assert.default.strictEqual(status, 200, "Expected 200 with the uplink up");
|
|
174
|
+
assert.default.strictEqual(body.objects.length, allNames.length, `Expected ${allNames.length} merged results, got ${body.objects.length}`);
|
|
175
|
+
await assertPaginationCovers(ctx.registryUrl, localPrefix, 5, allNames);
|
|
176
|
+
});
|
|
177
|
+
await check("merged total counts local and uplink matches", async () => {
|
|
178
|
+
const { body } = await searchV1(ctx.registryUrl, {
|
|
179
|
+
text: localPrefix,
|
|
180
|
+
size: 5
|
|
181
|
+
});
|
|
182
|
+
assert.default.strictEqual(body.total, 14, `Expected total=14, got ${body.total}`);
|
|
183
|
+
});
|
|
184
|
+
} finally {
|
|
185
|
+
await mock.stop();
|
|
186
|
+
}
|
|
187
|
+
} else require_process.trace("E2E_UPLINK_PORT not set — skipping merged local+uplink pagination sub-tests");
|
|
124
188
|
if (failed.length > 0) throw new Error(`${failed.length} search contract check(s) failed:\n - ${failed.join("\n - ")}`);
|
|
125
189
|
}
|
|
126
190
|
var searchScenario = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.cjs","names":[],"sources":["../../../src/scenarios/search.ts"],"sourcesContent":["import assert from 'assert';\n\nimport { TestContext, TestDefinition } from '../types';\nimport { MockUplink } from '../utils/mock-uplink';\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 * TODO: flip to true once S-1/S-3/S-4/S-5 from the search parity review are\n * fixed in verdaccio (see verdaccio packages/api/src/v1/search.ts). These\n * checks pin the correct npmjs contract but are red against every current\n * registry: real `total` (S-3), 400 without `text` (S-4), ISO `time` (S-5),\n * and single pagination of merged local+uplink results (S-1). Disabled so the\n * scenario gates regressions on the behavior that works today without turning\n * CI red.\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = false;\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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"}
|
|
1
|
+
{"version":3,"file":"search.cjs","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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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
|
|
2
2
|
const require_process = require("../utils/process.cjs");
|
|
3
3
|
const require_http_client = require("../utils/http-client.cjs");
|
|
4
|
+
const require_pending_checks = require("../utils/pending-checks.cjs");
|
|
4
5
|
let path = require("path");
|
|
5
6
|
let fs_promises = require("fs/promises");
|
|
6
7
|
let assert = require("assert");
|
|
@@ -31,6 +32,14 @@ let crypto = require("crypto");
|
|
|
31
32
|
*/
|
|
32
33
|
var LARGE_MB = parseInt(process.env.E2E_LARGE_TARBALL_MB || "30", 10);
|
|
33
34
|
var CONCURRENT_DOWNLOADS = 8;
|
|
35
|
+
/**
|
|
36
|
+
* Contract checks that are red against current registries: tarballs are
|
|
37
|
+
* streamed chunked with no Content-Length, and compression() re-gzips the
|
|
38
|
+
* already-compressed .tgz for gzip-accepting clients. Off by default so CI
|
|
39
|
+
* stays green; enable with E2E_PENDING_CONTRACT_CHECKS=true (and make that
|
|
40
|
+
* the default once the fixes land in a published verdaccio).
|
|
41
|
+
*/
|
|
42
|
+
var PENDING_CONTRACT_CHECKS_ENABLED = require_pending_checks.pendingContractChecksEnabled("tarballs");
|
|
34
43
|
async function publishWithPayload(ctx, pkgName, version, payloadBytes) {
|
|
35
44
|
const { tempFolder } = await ctx.adapter.prepareProject(pkgName, version, ctx.registryUrl, ctx.port, ctx.token);
|
|
36
45
|
if (payloadBytes) await (0, fs_promises.writeFile)((0, path.join)(tempFolder, "payload.bin"), (0, crypto.randomBytes)(payloadBytes));
|
|
@@ -69,6 +78,20 @@ async function testTarballs(ctx) {
|
|
|
69
78
|
assert.default.strictEqual(download.integrity, dist.integrity, "integrity mismatch on downloaded tarball");
|
|
70
79
|
largeSha1 = download.sha1;
|
|
71
80
|
});
|
|
81
|
+
if (PENDING_CONTRACT_CHECKS_ENABLED) {
|
|
82
|
+
await ctx.subTest("tarball response advertises Content-Length", async () => {
|
|
83
|
+
const response = await fetch(largeTarballUrl, { headers: { "accept-encoding": "identity" } });
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
const contentLength = response.headers.get("content-length");
|
|
86
|
+
assert.default.ok(contentLength, "Expected Content-Length on an identity-encoded tarball response");
|
|
87
|
+
assert.default.ok(parseInt(contentLength, 10) >= largeBytes, `Content-Length (${contentLength}) smaller than the payload (${largeBytes})`);
|
|
88
|
+
});
|
|
89
|
+
await ctx.subTest("tarball is not re-compressed for gzip-accepting clients", async () => {
|
|
90
|
+
const response = await fetch(largeTarballUrl, { headers: { "accept-encoding": "gzip" } });
|
|
91
|
+
await response.body?.cancel();
|
|
92
|
+
assert.default.strictEqual(response.headers.get("content-encoding"), null, "Registry re-compresses the already-gzipped .tgz (wasted CPU on every download)");
|
|
93
|
+
});
|
|
94
|
+
}
|
|
72
95
|
await ctx.subTest("abort mid-download is handled gracefully", async () => {
|
|
73
96
|
const abortThresholds = [
|
|
74
97
|
1,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tarballs.cjs","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 { 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 * TODO: flip to true once Verdaccio serves tarballs the way npmjs does.\n * Verified against current registries: local tarballs are always streamed\n * chunked with no Content-Length (even with Accept-Encoding: identity), and\n * the express compression() middleware re-gzips the already-compressed .tgz\n * whenever the client accepts gzip (npm and undici do by default) — wasted\n * CPU per download and the reason Content-Length disappears. These checks pin\n * the correct behavior but are red today.\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = false;\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAM,WAAW,SAAS,QAAQ,IAAI,wBAAwB,MAAM,EAAE;AACtE,IAAM,uBAAuB;AAa7B,eAAe,mBACb,KACA,SACA,SACA,cACe;CACf,MAAM,EAAE,eAAe,MAAM,IAAI,QAAQ,eACvC,SACA,SACA,IAAI,aACJ,IAAI,MACJ,IAAI,KACN;CACA,IAAI,cAEF,OAAA,GAAM,YAAA,UAAA,EAAA,GAAU,KAAA,KAAA,CAAK,YAAY,aAAa,IAAA,GAAG,OAAA,YAAA,CAAY,YAAY,CAAC;CAE5E,MAAM,IAAI,QAAQ,KAChB,EAAE,KAAK,WAAW,GAClB,WACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;CACA,gBAAA,MAAM,uCAAuC,SAAS,SAAS,gBAAgB,CAAC;AAClF;AAEA,eAAe,QACb,KACA,SACA,SACmE;CACnE,MAAM,EAAE,QAAQ,SAAS,MAAM,oBAAA,eAAe,IAAI,aAAa,OAAO;CACtE,OAAA,QAAO,YAAY,QAAQ,KAAK,uCAAuC,SAAS;CAChF,MAAM,OAAO,MAAM,WAAW,QAAQ,EAAE;CACxC,OAAA,QAAO,GAAG,MAAM,SAAS,6BAA6B,QAAQ,GAAG,SAAS;CAC1E,OAAO;AACT;AAEA,eAAe,aAAa,KAAiC;CAC3D,MAAM,KAAK,IAAI;CACf,MAAM,WAAW,qBAAqB;CACtC,MAAM,aAAa,WAAW,OAAO;CACrC,IAAI,kBAAkB;CACtB,IAAI,YAAY;CAEhB,MAAM,IAAI,QAAQ,2BAA2B,SAAS,OAAO,YAAY;EACvE,MAAM,mBAAmB,KAAK,UAAU,SAAS,UAAU;CAC7D,CAAC;CAED,MAAM,IAAI,QAAQ,+CAA+C,YAAY;EAC3E,MAAM,OAAO,MAAM,QAAQ,KAAK,UAAU,OAAO;EACjD,kBAAkB,KAAK;EAEvB,MAAM,WAAW,MAAM,oBAAA,gBAAgB,KAAK,OAAO;EACnD,gBAAA,MACE,+FACA,KAAK,SACL,SAAS,QACT,SAAS,OACT,SAAS,QAAQ,IAAI,gBAAgB,GACrC,SAAS,QAAQ,IAAI,kBAAkB,GACvC,SAAS,QAAQ,IAAI,mBAAmB,CAC1C;EACA,gBAAA,MAAM,2CAA2C,KAAK,QAAQ,KAAK,SAAS;EAC5E,OAAA,QAAO,YAAY,SAAS,QAAQ,KAAK,4BAA4B,KAAK,SAAS;EACnF,OAAA,QAAO,GACL,SAAS,SAAS,YAClB,uBAAuB,WAAW,uCAAuC,SAAS,OACpF;EAGA,MAAM,gBAAgB,SAAS,QAAQ,IAAI,gBAAgB;EAC3D,IAAI,eACF,OAAA,QAAO,YACL,SAAS,eAAe,EAAE,GAC1B,SAAS,OACT,mBAAmB,cAAc,mCAAmC,SAAS,MAAM,EACrF;EAIF,OAAA,QAAO,GAAG,KAAK,QAAQ,+CAA+C;EACtE,OAAA,QAAO,YAAY,SAAS,MAAM,KAAK,QAAQ,uCAAuC;EACtF,OAAA,QAAO,GAAG,KAAK,WAAW,kDAAkD;EAC5E,OAAA,QAAO,YACL,SAAS,WACT,KAAK,WACL,0CACF;EACA,YAAY,SAAS;CACvB,CAAC;CA2BD,MAAM,IAAI,QAAQ,4CAA4C,YAAY;EAIxE,MAAM,kBAAkB;GAAC;GAAG,KAAK,MAAM,aAAa,GAAI;GAAG,KAAK,MAAM,aAAa,EAAG;EAAC;EACvF,KAAK,MAAM,aAAa,iBAAiB;GACvC,MAAM,aAAa,IAAI,gBAAgB;GACvC,IAAI,UAAU;GACd,IAAI;IACF,MAAM,oBAAA,gBAAgB,iBAAiB;KACrC,QAAQ,WAAW;KACnB,UAAU,UAAU;MAClB,IAAI,SAAS,WACX,WAAW,MAAM;KAErB;IACF,CAAC;GACH,SAAS,KAAK;IACZ,UAAU;IACV,gBAAA,MACE,kDACA,WACA,eAAe,QAAQ,IAAI,UAAU,GACvC;GACF;GACA,OAAA,QAAO,GAAG,SAAS,oDAAoD,UAAU,QAAQ;EAC3F;EAGA,MAAM,OAAO,MAAM,MAAM,GAAG,IAAI,YAAY,QAAQ;EACpD,gBAAA,MAAM,0BAA0B,KAAK,MAAM;EAC3C,OAAA,QAAO,YAAY,KAAK,QAAQ,KAAK,+CAA+C;EACpF,MAAM,WAAW,MAAM,oBAAA,gBAAgB,eAAe;EACtD,gBAAA,MAAM,4CAA4C,SAAS,QAAQ,SAAS,KAAK;EACjF,OAAA,QAAO,YAAY,SAAS,QAAQ,KAAK,mCAAmC;EAC5E,OAAA,QAAO,YAAY,SAAS,MAAM,WAAW,2CAA2C;CAC1F,CAAC;CAED,MAAM,IAAI,QAAQ,GAAG,qBAAqB,wCAAwC,YAAY;EAC5F,MAAM,YAAY,MAAM,QAAQ,IAC9B,MAAM,KAAK,EAAE,QAAQ,qBAAqB,SAAS,oBAAA,gBAAgB,eAAe,CAAC,CACrF;EACA,KAAK,MAAM,YAAY,WAAW;GAChC,OAAA,QAAO,YAAY,SAAS,QAAQ,KAAK,4BAA4B;GACrE,OAAA,QAAO,YAAY,SAAS,MAAM,WAAW,+BAA+B;EAC9E;CACF,CAAC;CAED,MAAM,IAAI,QAAQ,+BAA+B,YAAY;EAC3D,MAAM,QAAQ;GAEZ,GAAG,oBAAA,aAAa,IAAI,aAAa,QAAQ,EAAE,KAAK,SAAS;GAEzD,GAAG,oBAAA,aAAa,IAAI,aAAa,kBAAkB,IAAI,EAAE,oBAAoB,GAAG;GAEhF,GAAG,oBAAA,aAAa,IAAI,aAAa,QAAQ,EAAE;EAC7C;EACA,KAAK,MAAM,OAAO,OAAO;GACvB,MAAM,WAAW,MAAM,MAAM,GAAG;GAEhC,MAAM,eAAe,MAAM,SAAS,KAAK;GACzC,gBAAA,MAAM,8BAA8B,KAAK,SAAS,QAAQ,aAAa,MAAM,GAAG,GAAG,CAAC;GACpF,OAAA,QAAO,YAAY,SAAS,QAAQ,KAAK,oBAAoB,IAAI,QAAQ,SAAS,QAAQ;EAC5F;CACF,CAAC;CAED,MAAM,IAAI,QAAQ,8CAA8C,YAAY;EAC1E,MAAM,YAAY,0BAA0B;EAC5C,MAAM,mBAAmB,KAAK,WAAW,OAAO;EAChD,MAAM,OAAO,MAAM,QAAQ,KAAK,WAAW,OAAO;EAClD,MAAM,WAAW,MAAM,oBAAA,gBAAgB,KAAK,OAAO;EACnD,OAAA,QAAO,YAAY,SAAS,QAAQ,KAAK,4BAA4B,KAAK,SAAS;EACnF,OAAA,QAAO,GAAG,SAAS,QAAQ,GAAG,yBAAyB;EACvD,OAAA,QAAO,GAAG,KAAK,QAAQ,4CAA4C;EACnE,OAAA,QAAO,YAAY,SAAS,MAAM,KAAK,QAAQ,gCAAgC;CACjF,CAAC;CAED,MAAM,IAAI,QAAQ,8CAA8C,YAAY;EAC1E,MAAM,EAAE,eAAe,MAAM,IAAI,QAAQ,eACvC,wBAAwB,MACxB,SACA,IAAI,aACJ,IAAI,MACJ,IAAI,OACJ,GAAG,WAAW,QAAQ,CACxB;EACA,MAAM,IAAI,QAAQ,KAChB,EAAE,KAAK,WAAW,GAClB,WACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;CACF,CAAC;AACH;AAEA,IAAa,mBAAmC;CAC9C,MAAM;CACN,UAAU,CAAC,WAAW,SAAS;CAE/B,YAAY,YAAY,QAAQ,SAAS;CACzC,SAAS;CACT,KAAK;AACP"}
|
|
1
|
+
{"version":3,"file":"tarballs.cjs","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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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/utils/pending-checks.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pending contract checks pin registry behavior that is correct per the
|
|
4
|
+
* npmjs contract but still red against some published Verdaccio versions.
|
|
5
|
+
* They are off by default; E2E_PENDING_CONTRACT_CHECKS enables them:
|
|
6
|
+
*
|
|
7
|
+
* E2E_PENDING_CONTRACT_CHECKS=true → all scenarios
|
|
8
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs → one scenario
|
|
9
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs,search → a list
|
|
10
|
+
*
|
|
11
|
+
* Flip the default once the registry-side fixes are published everywhere.
|
|
12
|
+
*/
|
|
13
|
+
function pendingContractChecksEnabled(scenario) {
|
|
14
|
+
const value = (process.env.E2E_PENDING_CONTRACT_CHECKS || "").toLowerCase();
|
|
15
|
+
if (value === "true" || value === "1" || value === "all") return true;
|
|
16
|
+
return value.split(",").map((entry) => entry.trim()).includes(scenario);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
exports.pendingContractChecksEnabled = pendingContractChecksEnabled;
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=pending-checks.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pending-checks.cjs","names":[],"sources":["../../../src/utils/pending-checks.ts"],"sourcesContent":["/**\n * Pending contract checks pin registry behavior that is correct per the\n * npmjs contract but still red against some published Verdaccio versions.\n * They are off by default; E2E_PENDING_CONTRACT_CHECKS enables them:\n *\n * E2E_PENDING_CONTRACT_CHECKS=true → all scenarios\n * E2E_PENDING_CONTRACT_CHECKS=tarballs → one scenario\n * E2E_PENDING_CONTRACT_CHECKS=tarballs,search → a list\n *\n * Flip the default once the registry-side fixes are published everywhere.\n */\nexport function pendingContractChecksEnabled(scenario: string): boolean {\n const value = (process.env.E2E_PENDING_CONTRACT_CHECKS || '').toLowerCase();\n if (value === 'true' || value === '1' || value === 'all') {\n return true;\n }\n return value\n .split(',')\n .map((entry) => entry.trim())\n .includes(scenario);\n}\n"],"mappings":";;;;;;;;;;;;AAWA,SAAgB,6BAA6B,UAA2B;CACtE,MAAM,SAAS,QAAQ,IAAI,+BAA+B,GAAA,CAAI,YAAY;CAC1E,IAAI,UAAU,UAAU,UAAU,OAAO,UAAU,OACjD,OAAO;CAET,OAAO,MACJ,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,SAAS,QAAQ;AACtB"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { trace } from "../utils/process.js";
|
|
2
|
-
import "../utils/mock-uplink.js";
|
|
2
|
+
import { MockUplink } from "../utils/mock-uplink.js";
|
|
3
|
+
import { pendingContractChecksEnabled } from "../utils/pending-checks.js";
|
|
3
4
|
import { publishLocalPackage } from "../utils/publish.js";
|
|
4
5
|
import assert from "assert";
|
|
5
6
|
//#region src/scenarios/search.ts
|
|
@@ -28,6 +29,18 @@ import assert from "assert";
|
|
|
28
29
|
* adapter to run once per suite.
|
|
29
30
|
*/
|
|
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
|
+
}
|
|
31
44
|
async function searchV1(registryUrl, query) {
|
|
32
45
|
const url = new URL("/-/v1/search", registryUrl);
|
|
33
46
|
for (const [key, value] of Object.entries(query)) if (value !== void 0) url.searchParams.set(key, String(value));
|
|
@@ -98,12 +111,37 @@ async function testSearch(ctx) {
|
|
|
98
111
|
assert.strictEqual(body.objects.length, LOCAL_COUNT, `Expected ${LOCAL_COUNT} results for ${localPrefix}, got ${body.objects.length}`);
|
|
99
112
|
for (const object of body.objects) {
|
|
100
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}`);
|
|
101
115
|
assert.ok(Array.isArray(object.package.maintainers), `maintainers must be an array on ${object.package.name}`);
|
|
102
116
|
}
|
|
103
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
|
+
});
|
|
104
127
|
await check("pagination walks local results without gaps", async () => {
|
|
105
128
|
await assertPaginationCovers(ctx.registryUrl, localPrefix, 3, localNames);
|
|
106
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
|
+
}
|
|
107
145
|
await check("oversized size is clamped, not an error", async () => {
|
|
108
146
|
const { status, body } = await searchV1(ctx.registryUrl, {
|
|
109
147
|
text: localPrefix,
|
|
@@ -118,7 +156,33 @@ async function testSearch(ctx) {
|
|
|
118
156
|
const results = JSON.parse(resp.stdout);
|
|
119
157
|
for (const name of localNames) assert.ok(results.find((item) => item.name === name), `Expected ${name} in npm search output`);
|
|
120
158
|
});
|
|
121
|
-
|
|
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");
|
|
122
186
|
if (failed.length > 0) throw new Error(`${failed.length} search contract check(s) failed:\n - ${failed.join("\n - ")}`);
|
|
123
187
|
}
|
|
124
188
|
var searchScenario = {
|
|
@@ -1 +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 { 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 * TODO: flip to true once S-1/S-3/S-4/S-5 from the search parity review are\n * fixed in verdaccio (see verdaccio packages/api/src/v1/search.ts). These\n * checks pin the correct npmjs contract but are red against every current\n * registry: real `total` (S-3), 400 without `text` (S-4), ISO `time` (S-5),\n * and single pagination of merged local+uplink results (S-1). Disabled so the\n * scenario gates regressions on the behavior that works today without turning\n * CI red.\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = false;\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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"}
|
|
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,IAAM,cAAc;AACpB,IAAM,eAAe;;;;;;;;AASrB,IAAM,kCAAkC,6BAA6B,QAAQ;AAE7E,SAAS,aAAqB;CAC5B,OAAO,SAAS,QAAQ,IAAI,mBAAmB,IAAI,EAAE;AACvD;AAOA,eAAe,SACb,aACA,OACyB;CACzB,MAAM,MAAM,IAAI,IAAI,gBAAgB,WAAW;CAC/C,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAC7C,IAAI,UAAU,KAAA,GACZ,IAAI,aAAa,IAAI,KAAK,OAAO,KAAK,CAAC;CAG3C,MAAM,UAAU,GAAG;CACnB,MAAM,UAAU,KAAK,IAAI;CACzB,MAAM,WAAW,MAAM,MAAM,GAAG;CAChC,IAAI;CACJ,IAAI;EACF,OAAO,KAAK,MAAM,MAAM,SAAS,KAAK,CAAC;CACzC,QAAQ;EACN,OAAO,KAAA;CACT;CACA,MACE,kDACA,KACA,SAAS,QACT,KAAK,IAAI,IAAI,SACb,MAAM,OACN,MAAM,SAAS,UAAU,KACxB,MAAM,WAAW,CAAC,EAAA,CAAG,KAAK,MAAW,GAAG,SAAS,IAAI,CAAC,CAAC,KAAK,IAAI,CACnE;CACA,OAAO;EAAE,QAAQ,SAAS;EAAQ;CAAK;AACzC;;;;;AAMA,eAAe,uBACb,aACA,MACA,MACA,eACe;CACf,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAK,IAAI,OAAO,GAAG,OAAO,cAAc,QAAQ,QAAQ,MAAM;EAC5D,MAAM,YAAY,cAAc,SAAS;EACzC,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,aAAa;GAAE;GAAM;GAAM;EAAK,CAAC;EACzE,OAAO,YAAY,QAAQ,KAAK,yBAAyB,MAAM;EAC/D,MAAM,QAAQ,KAAK,QAAQ,KAAK,MAAW,EAAE,QAAQ,IAAI;EACzD,MAAM,yCAAyC,MAAM,MAAM,MAAM,QAAQ,MAAM,KAAK,IAAI,CAAC;EACzF,OAAO,YACL,MAAM,QACN,KAAK,IAAI,MAAM,SAAS,GACxB,YAAY,KAAK,IAAI,MAAM,SAAS,EAAE,mBAAmB,KAAK,QAAQ,MAAM,OAAO,IAAI,MAAM,KAAK,IAAI,KAAK,aAAa,EAC1H;EACA,KAAK,MAAM,QAAQ,OAAO;GACxB,OAAO,GAAG,CAAC,KAAK,IAAI,IAAI,GAAG,qBAAqB,KAAK,uBAAuB,KAAK,EAAE;GACnF,KAAK,IAAI,IAAI;EACf;CACF;CACA,MAAM,UAAU,cAAc,QAAQ,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC;CAC9D,OAAO,YAAY,QAAQ,QAAQ,GAAG,8BAA8B,QAAQ,KAAK,IAAI,GAAG;AAC1F;AAEA,eAAe,WAAW,KAAiC;CACzD,MAAM,KAAK,IAAI;CACf,MAAM,cAAc,eAAe;CACnC,MAAM,aAAa,MAAM,KAAK,EAAE,QAAQ,YAAY,IAAI,GAAG,MAAM,GAAG,YAAY,IAAI,IAAI,GAAG;CAK3F,MAAM,SAAmB,CAAC;CAC1B,MAAM,QAAQ,OAAO,OAAe,OAA2C;EAC7E,IAAI;GACF,MAAM,IAAI,QAAQ,OAAO,EAAE;EAC7B,SAAS,KAAK;GACZ,MAAM,yBAAyB,OAAO,eAAe,QAAQ,IAAI,UAAU,GAAG;GAC9E,OAAO,KAAK,GAAG,MAAM,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,GAAG;EAC9E;CACF;CAGA,MAAM,IAAI,QAAQ,+BAA+B,YAAY;EAC3D,KAAK,MAAM,QAAQ,YACjB,MAAM,oBAAoB,KAAK,MAAM,OAAO;CAEhD,CAAC;CAED,MAAM,MAAM,6CAA6C,YAAY;EACnE,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,IAAI,aAAa;GAAE,MAAM;GAAa,MAAM;EAAI,CAAC;EACzF,OAAO,YAAY,QAAQ,KAAK,gCAAgC;EAChE,OAAO,GAAG,MAAM,QAAQ,KAAK,OAAO,GAAG,+BAA6B;EACpE,MAAM,2BAA2B,KAAK,QAAQ,EAAE;EAChD,OAAO,YACL,KAAK,QAAQ,QACb,aACA,YAAY,YAAY,eAAe,YAAY,QAAQ,KAAK,QAAQ,QAC1E;EACA,KAAK,MAAM,UAAU,KAAK,SAAS;GACjC,OAAO,GAAG,OAAO,SAAS,MAAM,WAAW,WAAW,GAAG,iCAAiC;GAC1F,IAAI,iCAGF,OAAO,GAAG,OAAO,QAAQ,SAAS,yBAAyB,OAAO,QAAQ,MAAM;GAElF,OAAO,GACL,MAAM,QAAQ,OAAO,QAAQ,WAAW,GAExC,mCAAmC,OAAO,QAAQ,MACpD;EACF;CACF,CAAC;CAED,IAAI,iCAEF,MAAM,MAAM,qDAAqD,YAAY;EAC3E,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,IAAI,aAAa;GAAE,MAAM;GAAa,MAAM;EAAE,CAAC;EACvF,OAAO,YAAY,QAAQ,KAAK,gCAAgC;EAChE,OAAO,YAAY,KAAK,QAAQ,QAAQ,GAAG,mCAAmC;EAC9E,OAAO,YACL,KAAK,OACL,aACA,kBAAkB,YAAY,sBAAsB,KAAK,OAC3D;CACF,CAAC;CAGH,MAAM,MAAM,+CAA+C,YAAY;EACrE,MAAM,uBAAuB,IAAI,aAAa,aAAa,GAAG,UAAU;CAC1E,CAAC;CAED,IAAI,iCAAiC;EAEnC,MAAM,MAAM,6CAA6C,YAAY;GACnE,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,IAAI,aAAa,CAAC,CAAC;GAC3D,OAAO,YAAY,QAAQ,KAAK,oCAAoC,QAAQ;GAC5E,OAAO,GAAG,MAAM,OAAO,6CAA2C;EACpE,CAAC;EAGD,MAAM,MAAM,iCAAiC,YAAY;GACvD,MAAM,EAAE,SAAS,MAAM,SAAS,IAAI,aAAa;IAAE,MAAM;IAAa,MAAM;GAAE,CAAC;GAC/E,OAAO,GAAG,KAAK,MAAM,2BAAyB;GAC9C,OAAO,MACL,OAAO,KAAK,IAAI,GAChB,wCACA,gCAAgC,KAAK,KAAK,EAC5C;EACF,CAAC;CACH;CAEA,MAAM,MAAM,2CAA2C,YAAY;EACjE,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,IAAI,aAAa;GAAE,MAAM;GAAa,MAAM;EAAO,CAAC;EAC5F,OAAO,YAAY,QAAQ,KAAK,qCAAqC;EACrE,OAAO,GAAG,KAAK,QAAQ,UAAU,KAAK,4CAA4C;CACpF,CAAC;CAED,MAAM,MAAM,2CAA2C,YAAY;EACjE,MAAM,EAAE,eAAe,MAAM,IAAI,QAAQ,eACvC,yBAAyB,MACzB,SACA,IAAI,aACJ,IAAI,MACJ,IAAI,KACN;EACA,MAAM,OAAO,MAAM,IAAI,QAAQ,KAC7B,EAAE,KAAK,WAAW,GAClB,UACA,aACA,UACA,GAAG,IAAI,QAAQ,YAAY,IAAI,WAAW,CAC5C;EACA,MAAM,UAAU,KAAK,MAAM,KAAK,MAAM;EACtC,KAAK,MAAM,QAAQ,YACjB,OAAO,GACL,QAAQ,MAAM,SAAc,KAAK,SAAS,IAAI,GAC9C,YAAY,KAAK,sBACnB;CAEJ,CAAC;CAKD,IAAI,mCAAmC,OAAO,SAAS,WAAW,CAAC,GAAG;EACpE,MAAM,OAAO,IAAI,WAAW,WAAW,CAAC;EACxC,MAAM,cAAc,MAAM,KAAK,EAAE,QAAQ,aAAa,IAAI,GAAG,MAAM,GAAG,YAAY,IAAI,IAAI,GAAG;EAC7F,KAAK,MAAM,QAAQ,aACjB,KAAK,gBAAgB,IAAI;EAE3B,MAAM,KAAK,MAAM;EACjB,IAAI;GACF,MAAM,MAAM,kDAAkD,YAAY;IACxE,MAAM,WAAW,CAAC,GAAG,YAAY,GAAG,WAAW;IAC/C,MAAM,EAAE,QAAQ,SAAS,MAAM,SAAS,IAAI,aAAa;KAAE,MAAM;KAAa,MAAM;IAAI,CAAC;IACzF,OAAO,YAAY,QAAQ,KAAK,iCAAiC;IACjE,OAAO,YACL,KAAK,QAAQ,QACb,SAAS,QACT,YAAY,SAAS,OAAO,uBAAuB,KAAK,QAAQ,QAClE;IACA,MAAM,uBAAuB,IAAI,aAAa,aAAa,GAAG,QAAQ;GACxE,CAAC;GAED,MAAM,MAAM,gDAAgD,YAAY;IACtE,MAAM,EAAE,SAAS,MAAM,SAAS,IAAI,aAAa;KAAE,MAAM;KAAa,MAAM;IAAE,CAAC;IAC/E,OAAO,YACL,KAAK,OACL,IACA,0BAAqD,KAAK,OAC5D;GACF,CAAC;EACH,UAAU;GACR,MAAM,KAAK,KAAK;EAClB;CACF,OACE,MAAM,6EAA6E;CAGrF,IAAI,OAAO,SAAS,GAClB,MAAM,IAAI,MACR,GAAG,OAAO,OAAO,yCAAyC,OAAO,KAAK,QAAQ,GAChF;AAEJ;AAEA,IAAa,iBAAiC;CAC5C,MAAM;CACN,UAAU,CAAC,WAAW,QAAQ;CAC9B,SAAS;CAET,YAAY,YAAY,QAAQ,SAAS;CACzC,KAAK;AACP"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { trace } from "../utils/process.js";
|
|
2
2
|
import { downloadTarball, fetchPackument, packumentUrl } from "../utils/http-client.js";
|
|
3
|
+
import { pendingContractChecksEnabled } from "../utils/pending-checks.js";
|
|
3
4
|
import { join } from "path";
|
|
4
5
|
import { writeFile } from "fs/promises";
|
|
5
6
|
import assert from "assert";
|
|
@@ -29,6 +30,14 @@ import { randomBytes } from "crypto";
|
|
|
29
30
|
*/
|
|
30
31
|
var LARGE_MB = parseInt(process.env.E2E_LARGE_TARBALL_MB || "30", 10);
|
|
31
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");
|
|
32
41
|
async function publishWithPayload(ctx, pkgName, version, payloadBytes) {
|
|
33
42
|
const { tempFolder } = await ctx.adapter.prepareProject(pkgName, version, ctx.registryUrl, ctx.port, ctx.token);
|
|
34
43
|
if (payloadBytes) await writeFile(join(tempFolder, "payload.bin"), randomBytes(payloadBytes));
|
|
@@ -67,6 +76,20 @@ async function testTarballs(ctx) {
|
|
|
67
76
|
assert.strictEqual(download.integrity, dist.integrity, "integrity mismatch on downloaded tarball");
|
|
68
77
|
largeSha1 = download.sha1;
|
|
69
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
|
+
}
|
|
70
93
|
await ctx.subTest("abort mid-download is handled gracefully", async () => {
|
|
71
94
|
const abortThresholds = [
|
|
72
95
|
1,
|
|
@@ -1 +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 { 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 * TODO: flip to true once Verdaccio serves tarballs the way npmjs does.\n * Verified against current registries: local tarballs are always streamed\n * chunked with no Content-Length (even with Accept-Encoding: identity), and\n * the express compression() middleware re-gzips the already-compressed .tgz\n * whenever the client accepts gzip (npm and undici do by default) — wasted\n * CPU per download and the reason Content-Length disappears. These checks pin\n * the correct behavior but are red today.\n */\nconst PENDING_CONTRACT_CHECKS_ENABLED = false;\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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"}
|
|
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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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/utils/pending-checks.ts
|
|
2
|
+
/**
|
|
3
|
+
* Pending contract checks pin registry behavior that is correct per the
|
|
4
|
+
* npmjs contract but still red against some published Verdaccio versions.
|
|
5
|
+
* They are off by default; E2E_PENDING_CONTRACT_CHECKS enables them:
|
|
6
|
+
*
|
|
7
|
+
* E2E_PENDING_CONTRACT_CHECKS=true → all scenarios
|
|
8
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs → one scenario
|
|
9
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs,search → a list
|
|
10
|
+
*
|
|
11
|
+
* Flip the default once the registry-side fixes are published everywhere.
|
|
12
|
+
*/
|
|
13
|
+
function pendingContractChecksEnabled(scenario) {
|
|
14
|
+
const value = (process.env.E2E_PENDING_CONTRACT_CHECKS || "").toLowerCase();
|
|
15
|
+
if (value === "true" || value === "1" || value === "all") return true;
|
|
16
|
+
return value.split(",").map((entry) => entry.trim()).includes(scenario);
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { pendingContractChecksEnabled };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=pending-checks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pending-checks.js","names":[],"sources":["../../../src/utils/pending-checks.ts"],"sourcesContent":["/**\n * Pending contract checks pin registry behavior that is correct per the\n * npmjs contract but still red against some published Verdaccio versions.\n * They are off by default; E2E_PENDING_CONTRACT_CHECKS enables them:\n *\n * E2E_PENDING_CONTRACT_CHECKS=true → all scenarios\n * E2E_PENDING_CONTRACT_CHECKS=tarballs → one scenario\n * E2E_PENDING_CONTRACT_CHECKS=tarballs,search → a list\n *\n * Flip the default once the registry-side fixes are published everywhere.\n */\nexport function pendingContractChecksEnabled(scenario: string): boolean {\n const value = (process.env.E2E_PENDING_CONTRACT_CHECKS || '').toLowerCase();\n if (value === 'true' || value === '1' || value === 'all') {\n return true;\n }\n return value\n .split(',')\n .map((entry) => entry.trim())\n .includes(scenario);\n}\n"],"mappings":";;;;;;;;;;;;AAWA,SAAgB,6BAA6B,UAA2B;CACtE,MAAM,SAAS,QAAQ,IAAI,+BAA+B,GAAA,CAAI,YAAY;CAC1E,IAAI,UAAU,UAAU,UAAU,OAAO,UAAU,OACjD,OAAO;CAET,OAAO,MACJ,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,SAAS,QAAQ;AACtB"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending contract checks pin registry behavior that is correct per the
|
|
3
|
+
* npmjs contract but still red against some published Verdaccio versions.
|
|
4
|
+
* They are off by default; E2E_PENDING_CONTRACT_CHECKS enables them:
|
|
5
|
+
*
|
|
6
|
+
* E2E_PENDING_CONTRACT_CHECKS=true → all scenarios
|
|
7
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs → one scenario
|
|
8
|
+
* E2E_PENDING_CONTRACT_CHECKS=tarballs,search → a list
|
|
9
|
+
*
|
|
10
|
+
* Flip the default once the registry-side fixes are published everywhere.
|
|
11
|
+
*/
|
|
12
|
+
export declare function pendingContractChecksEnabled(scenario: string): boolean;
|