Skip to content

Commit 2a23347

Browse files
ruimartinarcanis
andauthored
feat: catalog support (#6884)
## What's the problem this PR addresses? Resolves: #6400 by implementing basic support for using catalog This PR implements catalog via the project configuration `yarnrc.yml`. We are opting for it instead of `package.json` to prevent adding complexity in situations where catalogs with the same name could be implemented at different scopes. Named catalogs should be able to address most of use cases. ## How did you fix it? - Adds support for base catalog and named catalogs by: - Implements a new plugin-catalog that hooks into `reduceDependency` and replaces catalog ranges with the ones defined in a catalog - Hooks into `beforeWorkspacePacking` replacing catalogs with actual ranges before packing - Adds relevant unit and integration tests ## QA Instructions - Edit and play with the catalog definition on `.yarnrc.yml` and run `yarn` to see error scenarios (by removing entries or naming then incorrectly), changing versions, etc. https://github.com/user-attachments/assets/29decd4f-d6a1-4a5e-b8d4-811e3730df2c - When running `yarn pack`, the resulting package should have no references to `catalog:` on package.json files. ## Checklist <!--- Don't worry if you miss something, chores are automatically tested. --> <!--- This checklist exists to help you remember doing the chores when you submit a PR. --> <!--- Put an `x` in all the boxes that apply. --> - [x] I have read the [Contributing Guide](https://yarnpkg.com/advanced/contributing). <!-- See https://yarnpkg.com/advanced/contributing#preparing-your-pr-to-be-released for more details. --> <!-- Check with `yarn version check` and fix with `yarn version check -i` --> - [x] I have set the packages that need to be released for my changes to be effective. <!-- The "Testing chores" workflow validates that your PR follows our guidelines. --> <!-- If it doesn't pass, click on it to see details as to what your PR might be missing. --> - [x] I will check that all automated PR checks pass before the PR gets reviewed. --------- Co-authored-by: Maël Nison <[email protected]>
1 parent 58f1b57 commit 2a23347

File tree

13 files changed

+1645
-43
lines changed

13 files changed

+1645
-43
lines changed

.pnp.cjs

Lines changed: 398 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.yarn/versions/ae3cd5fb.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
releases:
2+
"@yarnpkg/cli": minor
3+
"@yarnpkg/plugin-catalog": major
4+
"@yarnpkg/plugin-pack": patch
5+
6+
declined:
7+
- "@yarnpkg/plugin-compat"
8+
- "@yarnpkg/plugin-constraints"
9+
- "@yarnpkg/plugin-dlx"
10+
- "@yarnpkg/plugin-essentials"
11+
- "@yarnpkg/plugin-init"
12+
- "@yarnpkg/plugin-interactive-tools"
13+
- "@yarnpkg/plugin-nm"
14+
- "@yarnpkg/plugin-npm"
15+
- "@yarnpkg/plugin-npm-cli"
16+
- "@yarnpkg/plugin-patch"
17+
- "@yarnpkg/plugin-pnp"
18+
- "@yarnpkg/plugin-pnpm"
19+
- "@yarnpkg/plugin-stage"
20+
- "@yarnpkg/plugin-typescript"
21+
- "@yarnpkg/plugin-version"
22+
- "@yarnpkg/plugin-workspace-tools"
23+
- "@yarnpkg/builder"
24+
- "@yarnpkg/core"
25+
- "@yarnpkg/doctor"
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import {PortablePath, xfs} from '@yarnpkg/fslib';
2+
import {yarn, fs as fsUtils} from 'pkg-tests-core';
3+
4+
describe(`Features`, () => {
5+
describe(`Catalogs`, () => {
6+
test(
7+
`it should resolve dependencies from the default catalog during install`,
8+
makeTemporaryEnv(
9+
{
10+
dependencies: {
11+
[`no-deps`]: `catalog:`,
12+
},
13+
},
14+
async ({path, run, source}) => {
15+
await yarn.writeConfiguration(path, {
16+
catalog: {
17+
[`no-deps`]: `2.0.0`,
18+
},
19+
});
20+
21+
await run(`install`);
22+
23+
await expect(source(`require('no-deps')`)).resolves.toMatchObject({
24+
name: `no-deps`,
25+
version: `2.0.0`,
26+
});
27+
},
28+
),
29+
);
30+
31+
test(
32+
`it should resolve dependencies from named catalogs during install`,
33+
makeTemporaryEnv(
34+
{
35+
dependencies: {
36+
[`no-deps`]: `catalog:react18`,
37+
},
38+
},
39+
async ({path, run, source}) => {
40+
await yarn.writeConfiguration(path, {
41+
catalogs: {
42+
react18: {
43+
[`no-deps`]: `2.0.0`,
44+
},
45+
},
46+
});
47+
48+
await run(`install`);
49+
50+
await expect(source(`require('no-deps')`)).resolves.toMatchObject({
51+
name: `no-deps`,
52+
version: `2.0.0`,
53+
});
54+
},
55+
),
56+
);
57+
58+
test(
59+
`it should resolve scoped package dependencies from catalogs`,
60+
makeTemporaryEnv(
61+
{
62+
dependencies: {
63+
[`@scoped/create-test-app`]: `catalog:`,
64+
},
65+
},
66+
async ({path, run, source}) => {
67+
await yarn.writeConfiguration(path, {
68+
catalog: {
69+
[`@scoped/create-test-app`]: `1.0.0`,
70+
},
71+
});
72+
73+
await run(`install`);
74+
75+
// Verify that the scoped package was resolved from catalog
76+
const lockfile = await xfs.readFilePromise(`${path}/yarn.lock` as PortablePath, `utf8`);
77+
expect(lockfile).toMatch(/@scoped\/create-test-app@npm:1\.0\.0/);
78+
},
79+
),
80+
);
81+
82+
test(
83+
`it should support multiple catalog entries in the same project`,
84+
makeTemporaryEnv(
85+
{
86+
dependencies: {
87+
[`no-deps`]: `catalog:`,
88+
[`one-fixed-dep`]: `catalog:react18`,
89+
},
90+
},
91+
async ({path, run, source}) => {
92+
await yarn.writeConfiguration(path, {
93+
catalog: {
94+
[`no-deps`]: `2.0.0`,
95+
},
96+
catalogs: {
97+
react18: {
98+
[`one-fixed-dep`]: `1.0.0`,
99+
},
100+
},
101+
});
102+
103+
await run(`install`);
104+
105+
await expect(source(`require('no-deps')`)).resolves.toMatchObject({
106+
name: `no-deps`,
107+
version: `2.0.0`,
108+
});
109+
110+
await expect(source(`require('one-fixed-dep')`)).resolves.toMatchObject({
111+
name: `one-fixed-dep`,
112+
version: `1.0.0`,
113+
});
114+
},
115+
),
116+
);
117+
118+
test(
119+
`it should work with different dependency types (devDependencies, peerDependencies)`,
120+
makeTemporaryEnv(
121+
{
122+
dependencies: {
123+
[`one-fixed-dep`]: `catalog:tools`,
124+
},
125+
devDependencies: {
126+
[`no-deps`]: `catalog:`,
127+
},
128+
},
129+
async ({path, run}) => {
130+
await yarn.writeConfiguration(path, {
131+
catalog: {
132+
[`no-deps`]: `2.0.0`,
133+
},
134+
catalogs: {
135+
tools: {
136+
[`one-fixed-dep`]: `1.0.0`,
137+
},
138+
},
139+
});
140+
141+
await run(`install`);
142+
143+
// Check that the lockfile contains the resolved versions
144+
const lockfile = await xfs.readFilePromise(`${path}/yarn.lock` as PortablePath, `utf8`);
145+
expect(lockfile).toMatch(/no-deps@npm:2\.0\.0/);
146+
expect(lockfile).toMatch(/one-fixed-dep@npm:1\.0\.0/);
147+
},
148+
),
149+
);
150+
151+
test(
152+
`it should replace catalog references with actual versions during pack`,
153+
makeTemporaryEnv(
154+
{
155+
name: `my-package`,
156+
version: `1.0.0`,
157+
dependencies: {
158+
[`no-deps`]: `catalog:`,
159+
},
160+
devDependencies: {
161+
[`one-fixed-dep`]: `catalog:dev`,
162+
},
163+
},
164+
async ({path, run}) => {
165+
await yarn.writeConfiguration(path, {
166+
catalog: {
167+
[`no-deps`]: `^2.0.0`,
168+
},
169+
catalogs: {
170+
dev: {
171+
[`one-fixed-dep`]: `~1.0.0`,
172+
},
173+
},
174+
});
175+
176+
await run(`install`);
177+
await run(`pack`);
178+
179+
// Unpack the tarball and check the package.json content
180+
await fsUtils.unpackToDirectory(path, `${path}/package.tgz` as PortablePath);
181+
182+
const packedManifest = await xfs.readJsonPromise(`${path}/package/package.json` as PortablePath);
183+
184+
expect(packedManifest.dependencies[`no-deps`]).toBe(`npm:^2.0.0`);
185+
expect(packedManifest.devDependencies[`one-fixed-dep`]).toBe(`npm:~1.0.0`);
186+
},
187+
),
188+
);
189+
190+
test(
191+
`it should handle complex version ranges in catalogs`,
192+
makeTemporaryEnv(
193+
{
194+
dependencies: {
195+
[`no-deps`]: `catalog:`,
196+
},
197+
},
198+
async ({path, run, source}) => {
199+
await yarn.writeConfiguration(path, {
200+
catalog: {
201+
[`no-deps`]: `>=1.0.0 <3.0.0`,
202+
},
203+
});
204+
205+
await run(`install`);
206+
207+
// Should resolve to the highest compatible version (2.0.0)
208+
await expect(source(`require('no-deps')`)).resolves.toMatchObject({
209+
name: `no-deps`,
210+
version: `2.0.0`,
211+
});
212+
},
213+
),
214+
);
215+
216+
test(
217+
`it should throw an error when catalog is not found`,
218+
makeTemporaryEnv(
219+
{
220+
dependencies: {
221+
[`no-deps`]: `catalog:nonexistent`,
222+
},
223+
},
224+
async ({path, run}) => {
225+
await yarn.writeConfiguration(path, {
226+
catalog: {
227+
[`no-deps`]: `2.0.0`,
228+
},
229+
});
230+
231+
await expect(run(`install`)).rejects.toThrow();
232+
},
233+
),
234+
);
235+
236+
test(
237+
`it should throw an error when catalog entry is not found`,
238+
makeTemporaryEnv(
239+
{
240+
dependencies: {
241+
[`nonexistent-package`]: `catalog:`,
242+
},
243+
},
244+
async ({path, run}) => {
245+
await yarn.writeConfiguration(path, {
246+
catalog: {
247+
[`no-deps`]: `2.0.0`,
248+
},
249+
});
250+
251+
await expect(run(`install`)).rejects.toThrow();
252+
},
253+
),
254+
);
255+
256+
test(
257+
`it should throw an error when default catalog is empty`,
258+
makeTemporaryEnv(
259+
{
260+
dependencies: {
261+
[`no-deps`]: `catalog:`,
262+
},
263+
},
264+
async ({path, run}) => {
265+
await yarn.writeConfiguration(path, {
266+
catalog: {},
267+
});
268+
269+
await expect(run(`install`)).rejects.toThrow();
270+
},
271+
),
272+
);
273+
274+
test(
275+
`it should work with file: protocol ranges in catalogs`,
276+
makeTemporaryEnv(
277+
{
278+
dependencies: {
279+
[`my-local-package`]: `catalog:`,
280+
},
281+
},
282+
async ({path, run}) => {
283+
// Create a local package
284+
await xfs.mkdirPromise(`${path}/local-package` as PortablePath, {recursive: true});
285+
await xfs.writeJsonPromise(`${path}/local-package/package.json` as PortablePath, {
286+
name: `my-local-package`,
287+
version: `1.0.0`,
288+
});
289+
290+
await yarn.writeConfiguration(path, {
291+
catalog: {
292+
[`my-local-package`]: `file:./local-package`,
293+
},
294+
});
295+
296+
await run(`install`);
297+
298+
// Verify that the local package was installed
299+
const lockfile = await xfs.readFilePromise(`${path}/yarn.lock` as PortablePath, `utf8`);
300+
expect(lockfile).toMatch(/my-local-package@file:\.\/local-package/);
301+
},
302+
),
303+
);
304+
305+
test(
306+
`it should work in workspace environments`,
307+
makeTemporaryMonorepoEnv(
308+
{
309+
workspaces: [`packages/*`],
310+
},
311+
{
312+
packages: {},
313+
},
314+
async ({path, run}) => {
315+
// Create workspace package
316+
await xfs.mkdirPromise(`${path}/packages/workspace-a` as PortablePath, {recursive: true});
317+
await xfs.writeJsonPromise(`${path}/packages/workspace-a/package.json` as PortablePath, {
318+
name: `workspace-a`,
319+
version: `1.0.0`,
320+
dependencies: {
321+
[`no-deps`]: `catalog:`,
322+
},
323+
});
324+
325+
await yarn.writeConfiguration(path, {
326+
catalog: {
327+
[`no-deps`]: `2.0.0`,
328+
},
329+
});
330+
331+
await run(`install`);
332+
333+
// Verify that the workspace dependency was resolved from catalog
334+
const lockfile = await xfs.readFilePromise(`${path}/yarn.lock` as PortablePath, `utf8`);
335+
expect(lockfile).toMatch(/no-deps@npm:2\.0\.0/);
336+
},
337+
),
338+
);
339+
});
340+
});

0 commit comments

Comments
 (0)