Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

peerDep correctness #50

Merged
merged 14 commits into from
Sep 13, 2021
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,14 @@ When constructing a whole Project from a directory, you can choose to link all
dependencies instead of copying them in as Projects:

```js
let project = Project.fromDir("./sample-project", { linkDeps: true });
let project = Project.fromDir('./sample-project', { linkDeps: true });
project.files['extra.js'] = '// stuff';
project.write();
```

This will generate a new copy of sample-project, with symlinks to all its
original dependencies, but with "extra.js" added.

By default, `linkDeps` will only link up `dependencies` (which is appropriate
for libraries). If you want to also include `devDependencies` (which is
appropriate for apps) you can use `linkDevDeps` instead.
ef4 marked this conversation as resolved.
Show resolved Hide resolved
82 changes: 74 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import tmp = require('tmp');
import fs = require('fs-extra');
import path = require('path');
import resolvePackagePath = require('resolve-package-path');
import CacheGroup = require('resolve-package-path/lib/cache-group');
import { PackageJson } from 'type-fest';
import { entries } from 'walk-sync';

tmp.setGracefulCleanup();

Expand Down Expand Up @@ -90,6 +92,7 @@ function getPackageVersion(pkg: PackageJson): string {

interface ReadDirOpts {
linkDeps?: boolean;
linkDevDeps?: boolean;
}

// This only shallow-merges with any user-provided files, which is OK right now
Expand Down Expand Up @@ -125,6 +128,12 @@ export class Project {

private dependencyLinks: Map<string, { dir: string; requestedRange: string }> = new Map();
private linkIsDevDependency: Set<string> = new Set();
private usingHardLinks = true;

// we keep our own package resolution cache because the default global one
// could get polluted by us resolving test-specific things that will change on
// subsequent tests.
private resolutionCache = new CacheGroup();

constructor(
name?: string,
Expand Down Expand Up @@ -246,10 +255,6 @@ export class Project {
this.autoBaseDir();
fixturify.writeSync(this.baseDir, this.files);
fs.outputJSONSync(path.join(this.baseDir, 'package.json'), this.pkgJSONWithDeps(), { spaces: 2 });

for (let [name, { dir: target }] of this.dependencyLinks) {
fs.ensureSymlinkSync(target, path.join(this.baseDir, 'node_modules', name), 'dir');
}
for (let dep of this.dependencyProjects()) {
dep.baseDir = path.join(this.baseDir, 'node_modules', dep.name);
dep.writeSync();
Expand All @@ -258,6 +263,66 @@ export class Project {
dep.baseDir = path.join(this.baseDir, 'node_modules', dep.name);
dep.writeSync();
}
for (let [name, { dir: target }] of this.dependencyLinks) {
this.writeLinkedPackage(name, target);
}
}

private writeLinkedPackage(name: string, target: string) {
let targetPkg = fs.readJsonSync(`${target}/package.json`);
let peers = new Set(Object.keys(targetPkg.peerDependencies ?? {}));
let destination = path.join(this.baseDir, 'node_modules', name);

if (peers.size === 0) {
ef4 marked this conversation as resolved.
Show resolved Hide resolved
// no peerDeps, so we can just symlink the whole package
fs.ensureSymlinkSync(target, destination, 'dir');
return;
}

// need to reproduce the package structure in our own location
this.hardLinkContents(target, destination);

for (let section of ['dependencies', 'peerDependencies']) {
if (targetPkg[section]) {
for (let depName of Object.keys(targetPkg[section])) {
if (peers.has(depName)) {
continue;
}
let depTarget = resolvePackagePath(depName, target, this.resolutionCache);
if (!depTarget) {
throw new Error(
`[FixturifyProject] package ${name} in ${target} depends on ${depName} but we could not resolve it`
);
}
fs.ensureSymlinkSync(path.dirname(depTarget), path.join(destination, 'node_modules', depName));
}
}
}
}

private hardLinkContents(target: string, destination: string) {
for (let entry of entries(target, { ignore: ['node_modules'] })) {
if (entry.isDirectory()) {
this.hardLinkContents(entry.fullPath, path.join(destination, entry.relativePath));
} else {
this.hardLinkFile(entry.fullPath, path.join(destination, entry.relativePath));
}
}
}

private hardLinkFile(source: string, destination: string) {
if (this.usingHardLinks) {
try {
fs.ensureLinkSync(source, destination);
return;
} catch (err: any) {
if (err.code !== 'EXDEV') {
throw err;
}
this.usingHardLinks = false;
}
}
fs.copyFileSync(source, destination, fs.constants.COPYFILE_FICLONE);
}

static fromDir(root: string, opts?: ReadDirOpts): Project {
Expand All @@ -269,20 +334,21 @@ export class Project {
private readSync(root: string, opts?: ReadDirOpts): void {
const files = fixturify.readSync(root, {
// when linking deps, we don't need to crawl all of node_modules
ignore: opts?.linkDeps ? ['node_modules'] : [],
ignore: opts?.linkDeps || opts?.linkDevDeps ? ['node_modules'] : [],
});

this.pkg = deserializePackageJson(getFile(files, 'package.json'));
this.requestedRange = this.version;
delete files['package.json'];
this.files = files;

if (opts?.linkDeps) {
if (opts?.linkDeps || opts?.linkDevDeps) {
if (this.pkg.dependencies) {
for (let dep of Object.keys(this.pkg.dependencies)) {
this.linkDependency(dep, { baseDir: path.join(root, this.name) });
}
}
if (this.pkg.devDependencies) {
if (this.pkg.devDependencies && opts.linkDevDeps) {
for (let dep of Object.keys(this.pkg.devDependencies)) {
this.linkDevDependency(dep, { baseDir: path.join(root, this.name) });
}
Expand Down Expand Up @@ -433,7 +499,7 @@ export class Project {
this.removeDevDependency(name);
let dir: string;
if ('baseDir' in opts) {
let pkgJSONPath = resolvePackagePath(opts.resolveName || name, opts.baseDir);
let pkgJSONPath = resolvePackagePath(opts.resolveName || name, opts.baseDir, this.resolutionCache);
if (!pkgJSONPath) {
throw new Error(`failed to locate ${opts.resolveName || name} in ${opts.baseDir}`);
}
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fixturify-project",
"version": "3.0.2",
"name": "@ef4/fixturify-project",
ef4 marked this conversation as resolved.
Show resolved Hide resolved
"version": "4.0.0-alpha.2",
ef4 marked this conversation as resolved.
Show resolved Hide resolved
"main": "index.js",
"repository": "git@github.com:stefanpenner/node-fixturify-project",
"author": "Stefan Penner <stefan.penner@gmail.com>",
Expand All @@ -9,7 +9,8 @@
"fixturify": "^2.1.1",
"resolve-package-path": "^3.1.0",
"tmp": "^0.0.33",
"type-fest": "^0.11.0"
"type-fest": "^0.11.0",
"walk-sync": "^3.0.0"
},
"devDependencies": {
"@types/chai": "^4.2.18",
Expand Down
79 changes: 79 additions & 0 deletions test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,52 @@ describe('Project', function () {
);
});

it('adjusts peerDependencies of linked dependencies', function () {
let baseProject = new Project('base');
let alpha = baseProject.addDependency('alpha', {
files: {
'index.js': `
exports.betaVersion = function() {
return require('beta/package.json').version;
}
exports.gammaLocation = function() {
return require.resolve('gamma/package.json');
}
`,
deeper: {
'index.js': `
exports.betaVersion = function() {
return 'inner' + require('beta/package.json').version;
}
`,
},
},
});
alpha.pkg.peerDependencies = { beta: '^1.0.0' };
alpha.addDependency('gamma');
baseProject.addDependency('beta', { version: '1.1.0' });
baseProject.writeSync();

// precondition: in the baseProject, alpha sees its beta peerDep as beta@1.1.0
expect(require(require.resolve('alpha', { paths: [baseProject.baseDir] })).betaVersion()).to.eql('1.1.0');
ef4 marked this conversation as resolved.
Show resolved Hide resolved

let project = new Project('my-app');
project.linkDependency('alpha', { baseDir: baseProject.baseDir });
project.addDependency('beta', { version: '1.2.0' });
project.writeSync();

// in our linked project, alpha sees its beta peerDep as beta@1.2.0
expect(require(require.resolve('alpha', { paths: [project.baseDir] })).betaVersion()).to.eql('1.2.0');
ef4 marked this conversation as resolved.
Show resolved Hide resolved

// deeper modules in our package also work correctly
expect(require(require.resolve('alpha/deeper', { paths: [project.baseDir] })).betaVersion()).to.eql('inner1.2.0');
ef4 marked this conversation as resolved.
Show resolved Hide resolved

// unrelated dependencies are still shared
expect(require(require.resolve('alpha', { paths: [project.baseDir] })).gammaLocation()).to.eql(
ef4 marked this conversation as resolved.
Show resolved Hide resolved
require(require.resolve('alpha', { paths: [baseProject.baseDir] })).gammaLocation()
ef4 marked this conversation as resolved.
Show resolved Hide resolved
);
});

it('adds linked dependencies to package.json', function () {
let baseProject = new Project('base');
baseProject.addDependency('moment', '1.2.3');
Expand Down Expand Up @@ -519,6 +565,7 @@ describe('Project', function () {
// start with a template addon
let addonTemplate = new Project('stock-addon');
addonTemplate.addDependency('helper-lib', '1.2.3');
addonTemplate.addDevDependency('test-lib');
addonTemplate.files['hello.js'] = '// it works';
addonTemplate.writeSync();

Expand All @@ -535,10 +582,42 @@ describe('Project', function () {
expect(
fs.readlinkSync(path.join(myApp.baseDir, 'node_modules', 'custom-addon', 'node_modules', 'helper-lib'))
).to.eql(path.join(addonTemplate.baseDir, 'node_modules', 'helper-lib'));

// dev dependencies not included by default
expect(fs.existsSync(path.join(myApp.baseDir, 'node_modules', 'custom-addon', 'node_modules', 'test-lib'))).to.eql(
false
);

expect(fs.existsSync(path.join(myApp.baseDir, 'node_modules', 'custom-addon', 'hello.js'))).to.eql(true);
expect(fs.existsSync(path.join(myApp.baseDir, 'node_modules', 'custom-addon', 'layered-extra.js'))).to.eql(true);
});

it('can read a project with linked dev dependencies', function () {
// start with a template app
let appTemplate = new Project('stock-app');
appTemplate.addDependency('helper-lib', '1.2.3');
appTemplate.addDevDependency('test-lib');
appTemplate.files['hello.js'] = '// it works';
appTemplate.writeSync();

// build a new addon from the template
let myApp = Project.fromDir(appTemplate.baseDir, { linkDevDeps: true });
myApp.name = 'custom-addon';
myApp.files['layered-extra.js'] = '// extra stuff';
myApp.writeSync();

expect(fs.readlinkSync(path.join(myApp.baseDir, 'node_modules', 'helper-lib'))).to.eql(
path.join(appTemplate.baseDir, 'node_modules', 'helper-lib')
);

expect(fs.readlinkSync(path.join(myApp.baseDir, 'node_modules', 'test-lib'))).to.eql(
path.join(appTemplate.baseDir, 'node_modules', 'test-lib')
);

expect(fs.existsSync(path.join(myApp.baseDir, 'hello.js'))).to.eql(true);
expect(fs.existsSync(path.join(myApp.baseDir, 'layered-extra.js'))).to.eql(true);
});

it('can override a linked dependency with a new Project dependency', function () {
let baseProject = new Project('base');
baseProject.addDependency('moment', '1.2.3');
Expand Down
12 changes: 11 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@types/minimatch" "*"
"@types/node" "*"

"@types/minimatch@*", "@types/minimatch@^3.0.3":
"@types/minimatch@*", "@types/minimatch@^3.0.3", "@types/minimatch@^3.0.4":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
Expand Down Expand Up @@ -772,6 +772,16 @@ walk-sync@^2.0.2:
matcher-collection "^2.0.0"
minimatch "^3.0.4"

walk-sync@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/walk-sync/-/walk-sync-3.0.0.tgz#67f882925021e20569a1edd560b8da31da8d171c"
integrity sha512-41TvKmDGVpm2iuH7o+DAOt06yyu/cSHpX3uzAwetzASvlNtVddgIjXIb2DfB/Wa20B1Jo86+1Dv1CraSU7hWdw==
dependencies:
"@types/minimatch" "^3.0.4"
ensure-posix-path "^1.1.0"
matcher-collection "^2.0.1"
minimatch "^3.0.4"

which@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
Expand Down