Skip to content

Commit c6d4534

Browse files
gandazguljoaolucasl
authored andcommitted
fix(linker): Fix yarn removing linked deps during link stage (yarnpkg#4757)
**Summary** Actual fix: changed fs.readlink to fs.realpath when checking if a symlink is a linked dependency in package-linker.js This fixes yarn removing linked deps when installing or updating. Fixes yarnpkg#3288, fixes yarnpkg#4770, fixes yarnpkg#4635, fixes yarnpkg#4603. Potential fix for yarnpkg#3202. **Test plan** See yarnpkg#3288 (comment) for repro steps. See yarnpkg#3288 (comment) for my explanation of the problem. With a real world test scenario this works, but I'm unable to have it break from a unit test. I added a test in the integration suite but with the bug added back in it still passes because both generated paths are identical. I would like some help with the unit test.
1 parent 35687eb commit c6d4534

File tree

12 files changed

+106
-68
lines changed

12 files changed

+106
-68
lines changed

__tests__/commands/_helpers.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ import * as fs from '../../src/util/fs.js';
1010
import {Install} from '../../src/cli/commands/install.js';
1111
import Config from '../../src/config.js';
1212
import parsePackagePath from '../../src/util/parse-package-path.js';
13-
13+
import type {CLIFunctionReturn} from '../../src/types.js';
14+
import {run as link} from '../../src/cli/commands/link.js';
1415
const stream = require('stream');
1516
const path = require('path');
1617

17-
const fixturesLoc = path.join(__dirname, '..', 'fixtures', 'install');
18+
const installFixturesLoc = path.join(__dirname, '..', 'fixtures', 'install');
1819

1920
export const runInstall = run.bind(
2021
null,
2122
ConsoleReporter,
22-
fixturesLoc,
23+
installFixturesLoc,
2324
async (args, flags, config, reporter, lockfile): Promise<Install> => {
2425
const install = new Install(flags, config, reporter, lockfile);
2526
await install.init();
@@ -30,6 +31,17 @@ export const runInstall = run.bind(
3031
[],
3132
);
3233

34+
const linkFixturesLoc = path.join(__dirname, '..', 'fixtures', 'link');
35+
36+
export const runLink = run.bind(
37+
null,
38+
ConsoleReporter,
39+
linkFixturesLoc,
40+
(args, flags, config, reporter): CLIFunctionReturn => {
41+
return link(config, reporter, flags, args);
42+
},
43+
);
44+
3345
export async function createLockfile(dir: string): Promise<Lockfile> {
3446
const lockfileLoc = path.join(dir, constants.LOCKFILE_FILENAME);
3547
let lockfile;
@@ -94,7 +106,7 @@ export async function run<T, R>(
94106
) => Promise<T> | T,
95107
args: Array<string>,
96108
flags: Object,
97-
name: string | {source: string, cwd: string},
109+
name: string | {source?: string, cwd: string},
98110
checkInstalled: ?(config: Config, reporter: R, install: T, getStdout: () => string) => ?Promise<void>,
99111
beforeInstall: ?(cwd: string) => ?Promise<void>,
100112
): Promise<void> {
@@ -113,11 +125,16 @@ export async function run<T, R>(
113125
if (fixturesLoc) {
114126
const source = typeof name === 'string' ? name : name.source;
115127

116-
const dir = path.join(fixturesLoc, source);
117-
cwd = await fs.makeTempDir(path.basename(dir));
118-
await fs.copy(dir, cwd, reporter);
119-
if (typeof name !== 'string') {
120-
cwd = path.join(cwd, name.cwd);
128+
// if source wasn't set then assume we were given a complete path
129+
if (typeof source === 'undefined') {
130+
cwd = typeof name !== 'string' ? name.cwd : await fs.makeTempDir();
131+
} else {
132+
const dir = path.join(fixturesLoc, source);
133+
cwd = await fs.makeTempDir(path.basename(dir));
134+
await fs.copy(dir, cwd, reporter);
135+
if (typeof name !== 'string') {
136+
cwd = path.join(cwd, name.cwd);
137+
}
121138
}
122139
} else {
123140
// if fixture loc is not set then CWD is some empty temp dir

__tests__/commands/install/integration.js

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {parse} from '../../../src/lockfile';
1111
import {Install, run as install} from '../../../src/cli/commands/install.js';
1212
import Lockfile from '../../../src/lockfile';
1313
import * as fs from '../../../src/util/fs.js';
14-
import {getPackageVersion, explodeLockfile, runInstall, createLockfile, run as buildRun} from '../_helpers.js';
14+
import {getPackageVersion, explodeLockfile, runInstall, runLink, createLockfile, run as buildRun} from '../_helpers.js';
1515

1616
jasmine.DEFAULT_TIMEOUT_INTERVAL = 150000;
1717

@@ -866,29 +866,6 @@ test('install a scoped module from authed private registry with a missing traili
866866
});
867867
});
868868

869-
test.concurrent('install will not overwrite files in symlinked scoped directories', async (): Promise<void> => {
870-
await runInstall(
871-
{},
872-
'install-dont-overwrite-linked-scoped',
873-
async (config): Promise<void> => {
874-
const dependencyPath = path.join(config.cwd, 'node_modules', '@fakescope', 'fake-dependency');
875-
expect('Symlinked scoped package test').toEqual(
876-
(await fs.readJson(path.join(dependencyPath, 'package.json'))).description,
877-
);
878-
expect(await fs.exists(path.join(dependencyPath, 'index.js'))).toEqual(false);
879-
},
880-
async cwd => {
881-
const dirToLink = path.join(cwd, 'dir-to-link');
882-
883-
await fs.mkdirp(path.join(cwd, '.yarn-link', '@fakescope'));
884-
await fs.symlink(dirToLink, path.join(cwd, '.yarn-link', '@fakescope', 'fake-dependency'));
885-
886-
await fs.mkdirp(path.join(cwd, 'node_modules', '@fakescope'));
887-
await fs.symlink(dirToLink, path.join(cwd, 'node_modules', '@fakescope', 'fake-dependency'));
888-
},
889-
);
890-
});
891-
892869
test.concurrent('install of scoped package with subdependency conflict should pass check', (): Promise<void> => {
893870
return runInstall({}, 'install-scoped-package-with-subdependency-conflict', async (config, reporter) => {
894871
let allCorrect = true;
@@ -1134,3 +1111,54 @@ test.concurrent('warns for missing bundledDependencies', (): Promise<void> => {
11341111
'missing-bundled-dep',
11351112
);
11361113
});
1114+
1115+
test.concurrent('install will not overwrite linked scoped dependencies', async (): Promise<void> => {
1116+
// install only dependencies
1117+
await runInstall({production: true}, 'install-dont-overwrite-linked', async (installConfig): Promise<void> => {
1118+
// link our fake dep to the registry
1119+
await runLink([], {}, 'package-with-name-scoped', async (linkConfig): Promise<void> => {
1120+
// link our fake dependency in our node_modules
1121+
await runLink(
1122+
['@fakescope/a-package'],
1123+
{linkFolder: linkConfig.linkFolder},
1124+
{cwd: installConfig.cwd},
1125+
async (): Promise<void> => {
1126+
// check that it exists (just in case)
1127+
const existed = await fs.exists(path.join(installConfig.cwd, 'node_modules', '@fakescope', 'a-package'));
1128+
expect(existed).toEqual(true);
1129+
1130+
// run install to install dev deps which would remove the linked dep if the bug was present
1131+
await runInstall({linkFolder: linkConfig.linkFolder}, {cwd: installConfig.cwd}, async (): Promise<void> => {
1132+
// if the linked dep is still there is a win :)
1133+
const existed = await fs.exists(path.join(installConfig.cwd, 'node_modules', '@fakescope', 'a-package'));
1134+
expect(existed).toEqual(true);
1135+
});
1136+
},
1137+
);
1138+
});
1139+
});
1140+
});
1141+
1142+
test.concurrent('install will not overwrite linked dependencies', async (): Promise<void> => {
1143+
// install only dependencies
1144+
await runInstall({production: true}, 'install-dont-overwrite-linked', async (installConfig): Promise<void> => {
1145+
// link our fake dep to the registry
1146+
await runLink([], {}, 'package-with-name', async (linkConfig): Promise<void> => {
1147+
// link our fake dependency in our node_modules
1148+
await runLink(['a-package'], {linkFolder: linkConfig.linkFolder}, {cwd: installConfig.cwd}, async (): Promise<
1149+
void,
1150+
> => {
1151+
// check that it exists (just in case)
1152+
const existed = await fs.exists(path.join(installConfig.cwd, 'node_modules', 'a-package'));
1153+
expect(existed).toEqual(true);
1154+
1155+
// run install to install dev deps which would remove the linked dep if the bug was present
1156+
await runInstall({linkFolder: linkConfig.linkFolder}, {cwd: installConfig.cwd}, async (): Promise<void> => {
1157+
// if the linked dep is still there is a win :)
1158+
const existed = await fs.exists(path.join(installConfig.cwd, 'node_modules', 'a-package'));
1159+
expect(existed).toEqual(true);
1160+
});
1161+
});
1162+
});
1163+
});
1164+
});

__tests__/commands/link.js

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,12 @@
11
/* @flow */
22

3-
import {run as buildRun} from './_helpers.js';
4-
import {run as link} from '../../src/cli/commands/link.js';
3+
import {runLink} from './_helpers.js';
54
import {ConsoleReporter} from '../../src/reporters/index.js';
6-
import type {CLIFunctionReturn} from '../../src/types.js';
75
import mkdir from './../_temp.js';
86
import * as fs from '../../src/util/fs.js';
97

108
const path = require('path');
119

12-
const fixturesLoc = path.join(__dirname, '..', 'fixtures', 'link');
13-
const runLink = buildRun.bind(
14-
null,
15-
ConsoleReporter,
16-
fixturesLoc,
17-
(args, flags, config, reporter): CLIFunctionReturn => {
18-
return link(config, reporter, flags, args);
19-
},
20-
);
21-
2210
test.concurrent('creates folder in linkFolder', async (): Promise<void> => {
2311
const linkFolder = await mkdir('link-folder');
2412
await runLink([], {linkFolder}, 'package-with-name', async (config, reporter): Promise<void> => {

__tests__/fixtures/install/install-dont-overwrite-linked-scoped/.npmrc

Lines changed: 0 additions & 1 deletion
This file was deleted.

__tests__/fixtures/install/install-dont-overwrite-linked-scoped/dir-to-link/package.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

__tests__/fixtures/install/install-dont-overwrite-linked-scoped/package.json

Lines changed: 0 additions & 5 deletions
This file was deleted.

__tests__/fixtures/install/install-dont-overwrite-linked-scoped/yarn.lock

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"dependencies": {
3+
"left-pad": "1.1.3"
4+
},
5+
"devDependencies": {
6+
"is-buffer": "^1.1.5"
7+
}
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "@fakescope/a-package"
3+
}

src/package-linker.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,13 @@ export default class PackageLinker {
304304
const stat = await fs.lstat(entryPath);
305305

306306
if (stat.isSymbolicLink()) {
307-
const packageName = entry;
308-
linkTargets.set(packageName, await fs.readlink(entryPath));
307+
try {
308+
const entryTarget = await fs.realpath(entryPath);
309+
linkTargets.set(entry, entryTarget);
310+
} catch (err) {
311+
this.reporter.warn(this.reporter.lang('linkTargetMissing', entry));
312+
await fs.unlink(entryPath);
313+
}
309314
} else if (stat.isDirectory() && entry[0] === '@') {
310315
// if the entry is directory beginning with '@', then we're dealing with a package scope, which
311316
// means we must iterate inside to retrieve the package names it contains
@@ -317,7 +322,13 @@ export default class PackageLinker {
317322

318323
if (stat2.isSymbolicLink()) {
319324
const packageName = `${scopeName}/${entry2}`;
320-
linkTargets.set(packageName, await fs.readlink(entryPath2));
325+
try {
326+
const entryTarget = await fs.realpath(entryPath2);
327+
linkTargets.set(packageName, entryTarget);
328+
} catch (err) {
329+
this.reporter.warn(this.reporter.lang('linkTargetMissing', packageName));
330+
await fs.unlink(entryPath2);
331+
}
321332
}
322333
}
323334
}
@@ -334,7 +345,7 @@ export default class PackageLinker {
334345
if (
335346
(await fs.lstat(loc)).isSymbolicLink() &&
336347
linkTargets.has(packageName) &&
337-
linkTargets.get(packageName) === (await fs.readlink(loc))
348+
linkTargets.get(packageName) === (await fs.realpath(loc))
338349
) {
339350
possibleExtraneous.delete(loc);
340351
copyQueue.delete(loc);

src/reporters/lang/en.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ const messages = {
172172
linkUsing: 'Using linked module for $0.',
173173
linkDisusing: 'Removed linked module $0.',
174174
linkDisusingMessage: 'You will need to run `yarn` to re-install the package that was linked.',
175+
linkTargetMissing: 'The target of linked module $0 is missing. Removing link.',
175176

176177
createInvalidBin: 'Invalid bin entry found in package $0.',
177178
createMissingPackage:

0 commit comments

Comments
 (0)