Skip to content

Commit c02d930

Browse files
committed
fix up patch-commit handling of unconventional tarballs
1 parent ad85bd0 commit c02d930

File tree

6 files changed

+535
-4
lines changed

6 files changed

+535
-4
lines changed

.yarn/versions/34906c89.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
releases:
22
"@yarnpkg/cli": patch
33
"@yarnpkg/plugin-npm": patch
4+
"@yarnpkg/plugin-patch": minor
45

56
declined:
67
- "@yarnpkg/plugin-compat"
@@ -12,7 +13,6 @@ declined:
1213
- "@yarnpkg/plugin-nm"
1314
- "@yarnpkg/plugin-npm-cli"
1415
- "@yarnpkg/plugin-pack"
15-
- "@yarnpkg/plugin-patch"
1616
- "@yarnpkg/plugin-pnp"
1717
- "@yarnpkg/plugin-pnpm"
1818
- "@yarnpkg/plugin-stage"

packages/acceptance-tests/pkg-tests-specs/sources/commands/patchCommit.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import {Filename, npath, ppath, xfs} from '@yarnpkg/fslib';
22

3+
const {
4+
tests: {startPackageServer},
5+
fs: {writeFile},
6+
} = require(`pkg-tests-core`);
7+
38
describe(`Commands`, () => {
49
describe(`patch-commit`, () => {
510
test(
@@ -200,5 +205,100 @@ describe(`Commands`, () => {
200205
});
201206
}),
202207
);
208+
209+
test(
210+
`it should preserve __archiveUrl parameters in patch URLs`,
211+
makeTemporaryEnv({
212+
dependencies: {
213+
[`unconventional-tarball`]: `npm:1.0.0::__archiveUrl=https://registry.example.org/unconventional-tarball/tralala/unconventional-tarball-1.0.0.tgz`,
214+
},
215+
}, async ({path, run}) => {
216+
await run(`install`);
217+
218+
const {stdout} = await run(`patch`, `unconventional-tarball`, `--json`);
219+
const {path: updateFolderN} = JSON.parse(stdout);
220+
221+
const updateFolder = npath.toPortablePath(updateFolderN);
222+
const updateFile = ppath.join(updateFolder, `index.js`);
223+
224+
const fileSource = await xfs.readFilePromise(updateFile, `utf8`);
225+
const fileUser = fileSource.replace(
226+
`module.exports = require(\`./package.json\`);`,
227+
`module.exports = require(\`./package.json\`);\n\nmodule.exports.hello = \`world\`;`,
228+
);
229+
await xfs.writeFilePromise(updateFile, fileUser);
230+
231+
await run(`patch-commit`, `-s`, npath.fromPortablePath(updateFolder));
232+
233+
const manifest = await xfs.readJsonPromise(ppath.join(path, Filename.manifest));
234+
235+
expect(manifest.dependencies).toEqual({
236+
[`unconventional-tarball`]: expect.stringMatching(/^patch:unconventional-tarball@npm%3A1\.0\.0#.*::__archiveUrl=https%3A%2F%2Fregistry\.example\.org/),
237+
});
238+
239+
// Ensure the patch URL format is correct (not malformed)
240+
expect(manifest.dependencies[`unconventional-tarball`]).not.toMatch(/^patch:.*::.*#/);
241+
}),
242+
);
243+
244+
test(
245+
`it should generate proper patch URLs for packages with unconventional tarball urls`,
246+
makeTemporaryEnv(
247+
{
248+
dependencies: {
249+
[`unconventional-tarball`]: `npm:1.0.0::__archiveUrl=https://registry.example.org/unconventional-tarball/tralala/unconventional-tarball-1.0.0.tgz`,
250+
},
251+
},
252+
async ({path, run, source}) => {
253+
const url = await startPackageServer({type: `https`});
254+
255+
await writeFile(ppath.join(path, `.yarnrc.yml`), [
256+
`npmScopes:`,
257+
` private:`,
258+
` npmRegistryServer: "${url}"`,
259+
].join(`\n`));
260+
261+
await run(`install`);
262+
263+
// Use yarn patch to get a temporary folder
264+
const {stdout} = await run(`patch`, `unconventional-tarball`, `--json`);
265+
const {path: updateFolderN} = JSON.parse(stdout);
266+
267+
const updateFolder = npath.toPortablePath(updateFolderN);
268+
const updateFile = ppath.join(updateFolder, `index.js`);
269+
270+
// Make the same modification as our patch
271+
const fileSource = await xfs.readFilePromise(updateFile, `utf8`);
272+
const fileUser = fileSource.replace(
273+
`module.exports = require(\`./package.json\`);`,
274+
`module.exports = require(\`./package.json\`);\n\nmodule.exports.hello = \`world\`;`,
275+
);
276+
await xfs.writeFilePromise(updateFile, fileUser);
277+
278+
// Commit the patch
279+
await run(`patch-commit`, `-s`, npath.fromPortablePath(updateFolder));
280+
281+
// Read the manifest to check the generated patch URL format
282+
const manifest = await xfs.readJsonPromise(ppath.join(path, Filename.manifest));
283+
const patchUrl = manifest.dependencies[`unconventional-tarball`];
284+
285+
// Verify the patch URL has the correct format: patch:source#selector::params
286+
expect(patchUrl).toMatch(/^patch:unconventional-tarball@npm%3A1\.0\.0#.*::__archiveUrl=/);
287+
288+
// Verify it's NOT the malformed format: patch:source::params#selector
289+
expect(patchUrl).not.toMatch(/^patch:unconventional-tarball@npm%3A1\.0\.0::.*#.*$/);
290+
291+
// Run install again to apply the patch
292+
await run(`install`);
293+
294+
// Verify the patch was applied correctly
295+
await expect(source(`require('unconventional-tarball')`)).resolves.toMatchObject({
296+
name: `unconventional-tarball`,
297+
version: `1.0.0`,
298+
hello: `world`,
299+
});
300+
},
301+
),
302+
);
203303
});
204304
});

packages/acceptance-tests/pkg-tests-specs/sources/protocols/patch.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,5 +238,24 @@ describe(`Protocols`, () => {
238238
},
239239
),
240240
);
241+
test(
242+
`it should apply patches on packages with unconventional tarball urls (__archiveUrl)`,
243+
makeTemporaryEnv(
244+
{
245+
dependencies: {[`unconventional-tarball`]: `patch:unconventional-tarball@npm:1.0.0#${PATCH_NAME}::__archiveUrl=https://registry.example.org/unconventional-tarball/tralala/unconventional-tarball-1.0.0.tgz`},
246+
},
247+
async ({path, run, source}) => {
248+
await xfs.writeFilePromise(ppath.join(path, PATCH_NAME), NO_DEPS_PATCH);
249+
250+
await run(`install`);
251+
252+
await expect(source(`require('unconventional-tarball')`)).resolves.toMatchObject({
253+
name: `unconventional-tarball`,
254+
version: `1.0.0`,
255+
hello: `world`,
256+
});
257+
},
258+
),
259+
);
241260
});
242261
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {structUtils} from '@yarnpkg/core';
2+
3+
import {NpmTarballResolver} from '../sources/NpmTarballResolver';
4+
5+
describe(`NpmTarballResolver`, () => {
6+
describe(`supportsDescriptor`, () => {
7+
it(`should support npm descriptors with __archiveUrl parameters`, () => {
8+
const resolver = new NpmTarballResolver();
9+
const ident = structUtils.makeIdent(null, `test-package`);
10+
const descriptor = structUtils.makeDescriptor(ident, `npm:1.0.0::__archiveUrl=https://registry.example.org/test-package-1.0.0.tgz`);
11+
12+
expect(resolver.supportsDescriptor(descriptor, null as any)).toBe(true);
13+
});
14+
15+
it(`should not support npm descriptors without __archiveUrl parameters`, () => {
16+
const resolver = new NpmTarballResolver();
17+
const ident = structUtils.makeIdent(null, `test-package`);
18+
const descriptor = structUtils.makeDescriptor(ident, `npm:1.0.0`);
19+
20+
expect(resolver.supportsDescriptor(descriptor, null as any)).toBe(false);
21+
});
22+
23+
it(`should not support non-npm descriptors`, () => {
24+
const resolver = new NpmTarballResolver();
25+
const ident = structUtils.makeIdent(null, `test-package`);
26+
const descriptor = structUtils.makeDescriptor(ident, `file:./local-package.tgz`);
27+
28+
expect(resolver.supportsDescriptor(descriptor, null as any)).toBe(false);
29+
});
30+
});
31+
32+
describe(`supportsLocator`, () => {
33+
it(`should not support any locators (handled by NpmSemverResolver)`, () => {
34+
const resolver = new NpmTarballResolver();
35+
const ident = structUtils.makeIdent(null, `test-package`);
36+
const locator = structUtils.makeLocator(ident, `npm:1.0.0::__archiveUrl=https://registry.example.org/test-package-1.0.0.tgz`);
37+
38+
expect(resolver.supportsLocator(locator, null as any)).toBe(false);
39+
});
40+
});
41+
42+
describe(`getCandidates`, () => {
43+
it(`should convert descriptor to locator`, async () => {
44+
const resolver = new NpmTarballResolver();
45+
const ident = structUtils.makeIdent(null, `test-package`);
46+
const descriptor = structUtils.makeDescriptor(ident, `npm:1.0.0::__archiveUrl=https://registry.example.org/test-package-1.0.0.tgz`);
47+
48+
const candidates = await resolver.getCandidates(descriptor, {}, null as any);
49+
50+
expect(candidates.length).toBe(1);
51+
expect(candidates[0].identHash).toBe(descriptor.identHash);
52+
});
53+
});
54+
55+
describe(`getSatisfying`, () => {
56+
it(`should filter locators that match the descriptor`, async () => {
57+
const resolver = new NpmTarballResolver();
58+
const ident = structUtils.makeIdent(null, `test-package`);
59+
const descriptor = structUtils.makeDescriptor(ident, `npm:1.0.0::__archiveUrl=https://registry.example.org/test-package-1.0.0.tgz`);
60+
const locator1 = structUtils.makeLocator(ident, `npm:1.0.0::__archiveUrl=https://registry.example.org/test-package-1.0.0.tgz`);
61+
const locator2 = structUtils.makeLocator(ident, `npm:2.0.0::__archiveUrl=https://registry.example.org/test-package-2.0.0.tgz`);
62+
63+
const result = await resolver.getSatisfying(descriptor, {}, [locator1, locator2], null as any);
64+
65+
expect(result.locators.length).toBe(1);
66+
expect(result.locators[0].locatorHash).toBe(locator1.locatorHash);
67+
expect(result.sorted).toBe(false);
68+
});
69+
});
70+
});

packages/plugin-patch/sources/patchUtils.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Cache, structUtils, Locator, Descriptor, Ident, Project, ThrowReport, miscUtils, FetchOptions, Package, execUtils, FetchResult, semverUtils, hashUtils} from '@yarnpkg/core';
22
import {npath, PortablePath, xfs, ppath, NativePath, CwdFS} from '@yarnpkg/fslib';
3+
import * as querystring from 'querystring';
34

45
import {CACHE_VERSION} from './constants';
56
import {Hooks as PatchHooks} from './index';
@@ -18,7 +19,7 @@ function parseSpec<T>(spec: string, sourceParser: (source: string) => T) {
1819
throw new Error(`Patch locators must explicitly define their source`);
1920

2021
const patchPaths = selector
21-
? selector.split(/&/).map(path => npath.toPortablePath(path))
22+
? selector.split(/&/).map(npath.toPortablePath)
2223
: [];
2324

2425
const parentLocator = params && typeof params.locator === `string`
@@ -29,7 +30,18 @@ function parseSpec<T>(spec: string, sourceParser: (source: string) => T) {
2930
? params.version
3031
: null;
3132

32-
const sourceItem = sourceParser(source);
33+
// Separate patch-specific from source-specific parameters
34+
const patchParams = new Set([`locator`, `version`, `hash`]);
35+
const sourceParams = params
36+
? Object.fromEntries(Object.entries(params).filter(([key]) => !patchParams.has(key)))
37+
: {};
38+
39+
// Reconstruct source with its parameters
40+
const sourceWithParams = Object.keys(sourceParams).length > 0
41+
? `${source}::${querystring.stringify(normalizeParams(sourceParams))}`
42+
: source;
43+
44+
const sourceItem = sourceParser(sourceWithParams);
3345

3446
return {parentLocator, sourceItem, patchPaths, sourceVersion};
3547
}
@@ -91,11 +103,15 @@ function makeSpec<T>({parentLocator, sourceItem, patchPaths, sourceVersion, patc
91103
? {hash: patchHash}
92104
: {} as {};
93105

106+
const sourceString = sourceStringifier(sourceItem);
107+
const {params: sourceParams} = structUtils.parseRange(sourceString);
108+
94109
return structUtils.makeRange({
95110
protocol: `patch:`,
96-
source: sourceStringifier(sourceItem),
111+
source: sourceString,
97112
selector: patchPaths.join(`&`),
98113
params: {
114+
...normalizeParams(sourceParams),
99115
...sourceVersionSpread,
100116
...patchHashSpread,
101117
...parentLocatorSpread,
@@ -343,3 +359,25 @@ export function makePatchHash(
343359

344360
return hashUtils.makeHash(`${CACHE_VERSION}`, ...parts).slice(0, 6);
345361
}
362+
363+
/**
364+
* Normalizes ParsedUrlQuery to string-only parameters.
365+
*
366+
* Node.js querystring methods don't provide this conversion directly:
367+
* - Filters out undefined values
368+
* - Joins arrays with commas (not multiple key=value pairs)
369+
* - Ensures all values are strings
370+
*/
371+
export function normalizeParams(params: ReturnType<typeof structUtils.parseRange>[`params`]): {[key: string]: string} {
372+
if (!params) return {};
373+
374+
const result: {[key: string]: string} = {};
375+
376+
for (const [key, value] of Object.entries(params)) {
377+
if (value !== undefined) {
378+
result[key] = Array.isArray(value) ? value.join(`,`) : String(value);
379+
}
380+
}
381+
382+
return result;
383+
}

0 commit comments

Comments
 (0)