Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Throw on incorrect pnpm resolution-mode
Browse files Browse the repository at this point in the history
lolmaus committed Sep 26, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 188b123 commit db322b3
Showing 5 changed files with 265 additions and 189 deletions.
92 changes: 26 additions & 66 deletions lib/dependency-manager-adapters/pnpm.js
Original file line number Diff line number Diff line change
@@ -5,14 +5,11 @@ const fs = require('fs-extra');
const path = require('path');
const debug = require('debug')('ember-try:dependency-manager-adapter:pnpm');
const Backup = require('../utils/backup');
const { promisify } = require('util');
const exec = promisify(require('child_process').exec);
const semverLt = require('semver/functions/lt');
const semverGte = require('semver/functions/gte');

const PACKAGE_JSON = 'package.json';
const PNPM_LOCKFILE = 'pnpm-lock.yaml';
const NPMRC_CONFIG = '.npmrc';

module.exports = CoreObject.extend({
// This still needs to be `npm` because we're still reading the dependencies
@@ -26,8 +23,8 @@ module.exports = CoreObject.extend({
},

async setup() {
await this._throwOnResolutionMode();
await this.backup.addFiles([PACKAGE_JSON, PNPM_LOCKFILE]);
await this._updateNpmRc();
},

async changeToDependencySet(depSet) {
@@ -54,7 +51,6 @@ module.exports = CoreObject.extend({
await this.backup.restoreFiles([PACKAGE_JSON, PNPM_LOCKFILE]);
await this.backup.cleanUp();
await this._install();
await this._revertNpmRc();
} catch (e) {
console.log('Error cleaning up scenario:', e); // eslint-disable-line no-console
}
@@ -81,76 +77,40 @@ module.exports = CoreObject.extend({
* - pnpm version is within dangerous range and .npmrc exists — backup the .npmrc file and
* append` resolution-mode = highest` to .npmrc
*
* @param {undefined | string} version — this is only used in testing. Call this fucntion without
* arguments
* @returns Promise<void>
*/
async _updateNpmRc(version) {
if (!version) {
version = await this._getPnpmVersion();
}

if (!this._doesPnpmRequireResolutionModeFix(version)) {
return;
}

let npmrcPath = path.join(this.cwd, NPMRC_CONFIG);

if (fs.existsSync(npmrcPath)) {
await this.backup.addFile(NPMRC_CONFIG);

await fs.appendFileSync(npmrcPath, '\nresolution-mode = highest\n');
} else {
fs.writeFileSync(npmrcPath, 'resolution-mode = highest\n');
async _throwOnResolutionMode() {
let version = await this._getPnpmVersion();
let resolutionMode = await this._getResolutionMode();

if (this._isResolutionModeWrong(version, resolutionMode)) {
throw new Error(
'You are using an old version of pnpm that uses wrong resolution mode that violates ember-try expectations. Please either upgrade pnpm or set `resolution-mode` to `highest` in `.npmrc`.'
);
}
},

/**
* pnpm versions 8.0.0 through 8.6.* have the `resolution-mode` setting inverted to
* `lowest-direct`, which breaks ember-try. This method conditionally adds the necessary config to
* .npmrc to fix this.
*
* It covers the following cases:
* - pnpm version is out of dangerious range or cannot be retrieved — do not do anything
* - pnpm version is within dangerous range and the backup does not exist — delete the .npmrc
* - pnpm version is within dangerous range and the backup exists — rename the backup to .npmrc,
* overwriting the current .npmrc
*
* @param {undefined | string} version — this is only used in testing. Call this fucntion without
* arguments
* @returns Promise<void>
*/
async _revertNpmRc(version) {
if (!version) {
version = await this._getPnpmVersion();
}

if (!this._doesPnpmRequireResolutionModeFix(version)) {
return;
}

let npmrcPath = path.join(this.cwd, NPMRC_CONFIG);

if (this.backup.hasFile(NPMRC_CONFIG)) {
await this.backup.restoreFile(NPMRC_CONFIG);
} else {
if (fs.existsSync(npmrcPath)) {
fs.removeSync(npmrcPath);
}
}
async _getPnpmVersion() {
let result = await this.run('pnpm', ['--version'], { cwd: this.cwd });
return result.stdout.split('\n')[0];
},

async _getPnpmVersion(command = 'pnpm --version') {
try {
let result = await exec(command);
return result.stdout.split('\n')[0];
} catch (error) {
return null;
}
async _getResolutionMode() {
let result = await this.run('pnpm', ['config', 'get', 'resolution-mode'], { cwd: this.cwd });

return result.stdout.split('\n')[0];
},

_doesPnpmRequireResolutionModeFix(versionStr) {
return versionStr ? semverGte(versionStr, '8.0.0') && semverLt(versionStr, '8.7.0') : false;
_isResolutionModeWrong(versionStr, resolutionMode) {
// prettier-ignore
return (
resolutionMode.length
&& resolutionMode !== 'highest'
) || (
!resolutionMode.length
&& semverGte(versionStr, '8.0.0')
&& semverLt(versionStr, '8.7.0')
);
},

async _install(depSet) {
2 changes: 1 addition & 1 deletion lib/utils/run.js
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ const execa = require('execa');
const debug = require('debug')('ember-try:utils:run');

module.exports = async function run(command, args, _options) {
let options = Object.assign({ stdio: 'inherit', shell: true }, _options);
let options = Object.assign({ stdio: 'pipe', shell: true }, _options);

if (process.env.SHUT_UP) {
options.stdio = 'ignore';
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -44,6 +44,7 @@
},
"devDependencies": {
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"codecov": "^3.8.3",
"ember-cli": "~3.22.0",
"eslint": "^7.31.0",
352 changes: 230 additions & 122 deletions test/dependency-manager-adapters/pnpm-adapter-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
'use strict';

let expect = require('chai').expect;
let chai = require('chai');
let chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
let { expect } = chai;
let fs = require('fs-extra');
let path = require('path');
let tmp = require('tmp-sync');
@@ -12,6 +15,15 @@ let root = process.cwd();
let tmproot = path.join(root, 'tmp');
let tmpdir;

function setResolutionModeToHighest(dir) {
// package.json is required for `pnpm config get` to work
let packageJsonPath = path.join(dir, 'package.json');
fs.writeFileSync(packageJsonPath, '{"private": true}');

let npmrcPath = path.join(dir, '.npmrc');
fs.writeFileSync(npmrcPath, 'resolution-mode = highest');
}

describe('pnpm Adapter', () => {
beforeEach(() => {
tmpdir = tmp.in(tmproot);
@@ -25,11 +37,33 @@ describe('pnpm Adapter', () => {
});

describe('#setup', () => {
beforeEach(() => {
setResolutionModeToHighest(tmpdir);
});

it('backs up the `package.json` and `pnpm-lock.yaml` files', async () => {
await fs.outputJson('package.json', { originalPackageJSON: true });
await fs.outputFile('pnpm-lock.yaml', 'originalYAML: true\n');

let adapter = new PnpmAdapter({ cwd: tmpdir });
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '9.0.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);

let adapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });
await adapter.setup();

expect(await fs.readJson(adapter.backup.pathForFile('package.json'))).to.deep.equal({
@@ -43,7 +77,25 @@ describe('pnpm Adapter', () => {
it('ignores missing `pnpm-lock.yaml` files', async () => {
await fs.outputJson('package.json', { originalPackageJSON: true });

let adapter = new PnpmAdapter({ cwd: tmpdir });
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '9.0.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);

let adapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });
await adapter.setup();

expect(await fs.readJson(adapter.backup.pathForFile('package.json'))).to.deep.equal({
@@ -54,6 +106,10 @@ describe('pnpm Adapter', () => {
});

describe('#changeToDependencySet', () => {
beforeEach(() => {
setResolutionModeToHighest(tmpdir);
});

it('updates the `package.json` and runs `pnpm install`', async () => {
await fs.outputJson('package.json', {
devDependencies: {
@@ -71,6 +127,18 @@ describe('pnpm Adapter', () => {
expect(opts).to.have.property('cwd', tmpdir);
},
},
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '9.0.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);
@@ -111,7 +179,7 @@ describe('pnpm Adapter', () => {
expect(runCount).to.equal(1);
});

it('runs _updateNpmRc before _install', async () => {
it('runs _throwOnResolutionMode before _install', async () => {
await fs.outputJson('package.json', {
devDependencies: {
'ember-try-test-suite-helper': '0.1.0',
@@ -124,7 +192,7 @@ describe('pnpm Adapter', () => {

const updateStub = sinon.replace(
adapter,
'_updateNpmRc',
'_throwOnResolutionMode',
sinon.fake(() => {})
);

@@ -146,6 +214,10 @@ describe('pnpm Adapter', () => {
});

describe('#cleanup', () => {
beforeEach(() => {
setResolutionModeToHighest(tmpdir);
});

it('restores the `package.json` and `pnpm-lock.yaml` files, and then runs `pnpm install`', async () => {
await fs.outputJson('package.json', { originalPackageJSON: true });
await fs.outputFile('pnpm-lock.yaml', 'originalYAML: true\n');
@@ -160,6 +232,18 @@ describe('pnpm Adapter', () => {
expect(opts).to.have.property('cwd', tmpdir);
},
},
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '9.0.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);
@@ -182,33 +266,6 @@ describe('pnpm Adapter', () => {

expect(runCount).to.equal(1);
});

it('runs _revertNpmRc after _install', async () => {
await fs.outputJson('package.json', { modifiedPackageJSON: true });
await fs.outputJson('package.json.ember-try', { originalPackageJSON: true });
await fs.outputFile('pnpm-lock.yaml', 'modifiedYAML: true\n');
await fs.outputFile('pnpm-lock.ember-try.yaml', 'originalYAML: true\n');

let adapter = new PnpmAdapter({
cwd: tmpdir,
});

const revertStub = sinon.replace(
adapter,
'_revertNpmRc',
sinon.fake(() => {})
);

const installStub = sinon.replace(
adapter,
'_install',
sinon.fake(() => {})
);

await adapter.cleanup();

expect(revertStub.calledAfter(installStub)).to.be.true;
});
});

describe('#_packageJSONForDependencySet', () => {
@@ -393,21 +450,38 @@ describe('pnpm Adapter', () => {
});
});

describe('#_doesPnpmRequireResolutionModeFix', () => {
describe('#_isResolutionModeWrong', () => {
// prettier-ignore
[
{ given: null, expected: false },
{ given: '1.0.0', expected: false },
{ given: '7.9.9999', expected: false },
{ given: '8.0.0', expected: true },
{ given: '8.1.2', expected: true },
{ given: '8.6.9999', expected: true },
{ given: '8.7.0', expected: false },
{ given: '8.7.1', expected: false },
{ given: '9.0.0', expected: false },
].forEach(({ given, expected }) => {
it(`works with given version "${given}"`, () => {
{ pnpmVersion: '1.0.0', resolutionMode: '', expected: false },
{ pnpmVersion: '7.9.9999', resolutionMode: '', expected: false },
{ pnpmVersion: '8.0.0', resolutionMode: '', expected: true },
{ pnpmVersion: '8.1.2', resolutionMode: '', expected: true },
{ pnpmVersion: '8.6.9999', resolutionMode: '', expected: true },
{ pnpmVersion: '8.7.0', resolutionMode: '', expected: false },
{ pnpmVersion: '8.7.1', resolutionMode: '', expected: false },
{ pnpmVersion: '9.0.0', resolutionMode: '', expected: false },
{ pnpmVersion: '1.0.0', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '7.9.9999', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '8.0.0', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '8.1.2', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '8.6.9999', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '8.7.0', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '8.7.1', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '9.0.0', resolutionMode: 'highest', expected: false },
{ pnpmVersion: '1.0.0', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '7.9.9999', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '8.0.0', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '8.1.2', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '8.6.9999', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '8.7.0', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '8.7.1', resolutionMode: 'patrick', expected: true },
{ pnpmVersion: '9.0.0', resolutionMode: 'patrick', expected: true },
].forEach(({ pnpmVersion, resolutionMode, expected }) => {
it(`works with given version "${pnpmVersion}" and resolutionMode "${resolutionMode}"`, () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let result = npmAdapter._doesPnpmRequireResolutionModeFix(given);

let result = npmAdapter._isResolutionModeWrong(pnpmVersion, resolutionMode);
expect(result).equal(expected);
});
});
@@ -421,111 +495,145 @@ describe('pnpm Adapter', () => {
{ version: 'how the turntables' },
].forEach(({ version }) => {
it(`works with given version "${version}"`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let result = await npmAdapter._getPnpmVersion(`echo ${version}`);
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: `${version}\n` };
},
},
],
{ allowPassthrough: false }
);

let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });
let result = await npmAdapter._getPnpmVersion();
expect(result).equal(version);
});
});
});

describe('#_updateNpmRc', () => {
describe('when pnpm version requires the resolution-mode fix', () => {
it(`should create a new .npmrc file when none exists`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try');

await npmAdapter._updateNpmRc('8.6.0');

let actualFileContent = fs.readFileSync(npmrcPath, 'utf8');
let expectedFileContent = 'resolution-mode = highest\n';

expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent);
expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false;
});

it(`should update an npmrc file when it already exists`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = npmAdapter.backup.pathForFile('.npmrc');

fs.writeFileSync(npmrcPath, 'foo = bar\n');

await npmAdapter._updateNpmRc('8.6.0');
describe('#_getResolutionMode', () => {
it('when no .npmrc is present, it should return an empty string', async () => {
let stubbedRun = generateMockRun(
[
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: '' };
},
},
],
{ allowPassthrough: false }
);

let actualFileContent = fs.readFileSync(npmrcPath, 'utf8');
let expectedFileContent = 'foo = bar\n\nresolution-mode = highest\n';
expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent);
let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });

let actualBackupFileContent = fs.readFileSync(npmrcBackupPath, 'utf8');
let expectedBackupFileContent = 'foo = bar\n';
expect(actualBackupFileContent, '.npmrc-backup content').to.equal(
expectedBackupFileContent
);
});
let result = await npmAdapter._getResolutionMode();
expect(result).equal('');
});

describe('when pnpm version does not the resolution-mode fix', () => {
it(`should not create a new .npmrc file`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try');
it('when .npmrc contains reslution-mode, it should return the given resolution mode', async () => {
let stubbedRun = generateMockRun(
[
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);

await npmAdapter._updateNpmRc('7.6.0');
let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });

expect(fs.existsSync(npmrcPath), '.npmrc does not exist').to.be.false;
expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false;
});
setResolutionModeToHighest(tmpdir);

let result = await npmAdapter._getResolutionMode();
expect(result).equal('highest');
});
});

describe('#_revertNpmRc', () => {
describe('#_throwOnResolutionMode', () => {
describe('when pnpm version requires the resolution-mode fix', () => {
it(`when backup does not exist, it should delete the .npmrc file`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try');

fs.writeFileSync(npmrcPath, 'resolution-mode = highest\n');
it(`when resoultion-mode is not highest, should throw an error`, async () => {
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '8.6.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: '' };
},
},
],
{ allowPassthrough: false }
);

await npmAdapter._revertNpmRc('8.6.0');
let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });

expect(fs.existsSync(npmrcPath), '.npmrc.ember-try does not exist').to.be.false;
expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false;
return expect(npmAdapter._throwOnResolutionMode()).to.eventually.be.rejectedWith(
'You are using an old version of pnpm that uses wrong resolution mode that violates ember-try expectations. Please either upgrade pnpm or set `resolution-mode` to `highest` in `.npmrc`.'
);
});

it(`when backup exists, it should replace the original file with backup and delete the backup`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = npmAdapter.backup.pathForFile('.npmrc');

fs.writeFileSync(npmrcPath, 'foo = bar\n\nresolution-mode = highest\n');
fs.writeFileSync(npmrcBackupPath, 'foo = bar\n');
it(`when resoultion-mode is highest, should not throw an error`, async () => {
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '8.6.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);

await npmAdapter._revertNpmRc('8.6.0');
let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });

let actualFileContent = fs.readFileSync(npmrcPath, 'utf8');
let expectedFileContent = 'foo = bar\n';
setResolutionModeToHighest(tmpdir);

expect(actualFileContent, '.npmrc content').to.equal(expectedFileContent);
// expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try existence').to.be.false;
await npmAdapter._throwOnResolutionMode('8.6.0');
});
});

describe('when pnpm version does not the resolution-mode fix', () => {
it(`should not touch the existing .npmrc file`, async () => {
let npmAdapter = new PnpmAdapter({ cwd: tmpdir });
let npmrcPath = path.join(tmpdir, '.npmrc');
let npmrcBackupPath = path.join(tmpdir, '.npmrc.ember-try');

fs.writeFileSync(npmrcPath, 'foo = bar\n');

await npmAdapter._revertNpmRc('7.6.0');
it(`should not throw an error`, async () => {
let stubbedRun = generateMockRun(
[
{
command: 'pnpm --version',
async callback(/* command, args, opts */) {
return { stdout: '7.6.0\n' };
},
},
{
command: 'pnpm config get resolution-mode',
async callback(/* command, args, opts */) {
return { stdout: 'highest\n' };
},
},
],
{ allowPassthrough: false }
);

let actualFileContent = fs.readFileSync(npmrcPath, 'utf8');
let npmAdapter = new PnpmAdapter({ cwd: tmpdir, run: stubbedRun });

expect(actualFileContent, '.npmrc content').to.equal('foo = bar\n');
expect(fs.existsSync(npmrcBackupPath), '.npmrc.ember-try does not exist').to.be.false;
await npmAdapter._throwOnResolutionMode();
});
});
});
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
@@ -1717,6 +1717,13 @@ cardinal@^1.0.0:
ansicolors "~0.2.1"
redeyed "~1.0.0"

chai-as-promised@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0"
integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==
dependencies:
check-error "^1.0.2"

chai@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"

0 comments on commit db322b3

Please sign in to comment.