diff --git a/src/build-dep-graph.js b/src/build-dep-graph.js index c9c5490e..88894584 100644 --- a/src/build-dep-graph.js +++ b/src/build-dep-graph.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const semver = require('semver'); +const { satisfies } = require('./semver'); const dependencyTypes = require('./dependency-types'); const readJson = require('./json').read; const { getWorkspacesPaths } = require('./get-workspaces-paths'); @@ -46,7 +46,7 @@ function deleteOutOfRangePackages(_package, packages) { for (let dependencyType of dependencyTypes) { for (let packageName in _package[dependencyType]) { let versionRange = _package[dependencyType][packageName]; - if (semver.satisfies(packages[packageName].version, versionRange)) { + if (satisfies(packages[packageName].version, versionRange)) { continue; } diff --git a/src/build-release-graph.js b/src/build-release-graph.js index 4ba95b70..24b6b639 100644 --- a/src/build-release-graph.js +++ b/src/build-release-graph.js @@ -10,6 +10,7 @@ const dependencyTypes = require('./dependency-types'); const { loadPackageConfig } = require('./config'); const debug = require('./debug'); const { createSyncLogger, createAsyncLogger } = require('./log'); +const { satisfies } = require('./semver'); const defaultReleaseType = 'patch'; @@ -46,7 +47,7 @@ function isReleaseTypeLessThan(type1, type2) { } function isReleaseTypeInRange(version, type, range) { - return semver.satisfies(semver.inc(version, type), range); + return satisfies(semver.inc(version, type), range); } let shouldVersionBumpSymbol = Symbol('shouldVersionBump'); @@ -326,7 +327,7 @@ function fourthPass({ let newVersion = semver.inc(parent.oldVersion, parent.releaseType); - if (shouldBumpInRangeDependencies || !semver.satisfies(newVersion, newRange)) { + if (shouldBumpInRangeDependencies || !satisfies(newVersion, newRange)) { newRange = trackNewVersion({ name, oldRange, diff --git a/src/defrag.js b/src/defrag.js index 178c77d3..c11b13ce 100644 --- a/src/defrag.js +++ b/src/defrag.js @@ -9,7 +9,12 @@ const dependencyTypes = require('./dependency-types'); const semverMinVersion = require('semver/ranges/min-version'); const semverGt = require('semver/functions/gt'); const semverSatisfies = require('semver/functions/satisfies'); -const semverValidRange = require('semver/ranges/valid'); +const { + isValidRange, + isWorkspaceProtocol, + extractRange, + isWorkspaceProtocolReplacementVersion, +} = require('./semver'); const semverMajor = require('semver/functions/major'); const semverMinor = require('semver/functions/minor'); const Range = require('semver/classes/range'); @@ -62,18 +67,25 @@ function filterRangeUpdates(allRanges, { continue; } - if (!semverValidRange(oldRange) || !semverValidRange(newRange)) { + if (!isValidRange(oldRange) || !isValidRange(newRange)) { + continue; + } + + let oldSemverRange = extractRange(oldRange); + let newSemverRange = extractRange(newRange); + + if (isWorkspaceProtocolReplacementVersion(oldSemverRange) || isWorkspaceProtocolReplacementVersion(newSemverRange)) { continue; } // Assume ranges like "*" are monorepo links // and should be ignored. - if (new Range(oldRange).range === '') { + if (new Range(oldSemverRange).range === '') { continue; } - let oldMinVersion = semverMinVersion(oldRange); - let newMinVersion = semverMinVersion(newRange); + let oldMinVersion = semverMinVersion(oldSemverRange); + let newMinVersion = semverMinVersion(newSemverRange); let oldMajor = semverMajor(oldMinVersion).toString(); let oldMinor = semverMinor(oldMinVersion).toString(); @@ -82,7 +94,7 @@ function filterRangeUpdates(allRanges, { switch (outOfRange) { default: - isInRange = semverSatisfies(newMinVersion, oldRange); + isInRange = semverSatisfies(newMinVersion, oldSemverRange); break; case 'patch': isInRange = semverSatisfies(newMinVersion, `${oldMajor}.${oldMinor}`); @@ -101,7 +113,17 @@ function filterRangeUpdates(allRanges, { rangeUpdates[packageName] = {}; } - rangeUpdates[packageName][oldRange] = newRange; + let updatedRange = newRange; + + if (isWorkspaceProtocol(oldRange) !== isWorkspaceProtocol(newRange)) { + if (isWorkspaceProtocol(newRange)) { + updatedRange = newSemverRange; + } else { + updatedRange = `workspace:${newRange}`; + } + } + + rangeUpdates[packageName][oldRange] = updatedRange; } } } diff --git a/src/release.js b/src/release.js index 3221b6bc..151c2b0b 100644 --- a/src/release.js +++ b/src/release.js @@ -183,9 +183,18 @@ async function release({ let commitMessage = `chore(release): ${tags.join()}`; + let isPnpm = false; + let isYarn = false; + if (await fsExists(path.join(workspaceCwd, 'pnpm-lock.yaml'))) { - await module.exports.updatePnpmLockfile({ cwd: workspaceCwd, silent, dryRun }); + isPnpm = true; } else if (await fsExists(path.join(workspaceCwd, 'yarn.lock'))) { + isYarn = true; + } + + if (isPnpm) { + await module.exports.updatePnpmLockfile({ cwd: workspaceCwd, silent, dryRun }); + } else if (isYarn) { await module.exports.updateYarnLockfile({ cwd: workspaceCwd, silent, dryRun }); } @@ -250,7 +259,7 @@ async function release({ if (shouldPublish && _shouldPublish) { // eslint-disable-next-line no-inner-declarations async function originalPublish() { - await publish({ cwd, silent, distTag, dryRun }); + await publish({ cwd, silent, distTag, dryRun, isPnpm }); } if (publishOverride) { @@ -259,6 +268,7 @@ async function release({ originalPublish, distTag, dryRun, + isPnpm, }); } else { await originalPublish(); @@ -309,10 +319,11 @@ async function push({ cwd, silent, dryRun }) { } } -async function publish({ cwd, silent, distTag, dryRun }) { +async function publish({ cwd, silent, distTag, dryRun, isPnpm }) { + let command = isPnpm ? 'pnpm' : 'npm'; let dryRunArgs = dryRun ? ['--dry-run'] : []; - await execa('npm', ['publish', '--tag', distTag, ...dryRunArgs], { cwd, silent }); + await execa(command, ['publish', '--tag', distTag, ...dryRunArgs], { cwd, silent }); } async function updatePnpmLockfile({ cwd, silent, dryRun }) { diff --git a/src/semver.js b/src/semver.js new file mode 100644 index 00000000..ef30ec44 --- /dev/null +++ b/src/semver.js @@ -0,0 +1,67 @@ +'use strict'; + +const semver = require('semver'); + +/** + * @param {string} range + */ +function isWorkspaceProtocol(range) { + return range.startsWith('workspace:'); +} + +/** + * @param {string} range + */ +function extractRange(range) { + return range.replace(/^workspace:/, ''); +} + +/** + * @param {string} range + */ +function isWorkspaceProtocolReplacementVersion(range) { + if (range === '^' || range === '~') { + return true; + } else { + return false; + } +} + +/** + * @param {string} version + * @param {string} range + */ +function satisfies(version, range) { + if (isWorkspaceProtocol(range)) { + range = extractRange(range); + + if (isWorkspaceProtocolReplacementVersion(range)) { + return true; + } + } + + return semver.satisfies(version, range); +} + +/** + * @param {string} range + */ +function isValidRange(range) { + if (isWorkspaceProtocol(range)) { + range = extractRange(range); + + if (isWorkspaceProtocolReplacementVersion(range)) { + return true; + } + } + + return semver.validRange(range); +} + +module.exports = { + isWorkspaceProtocol, + extractRange, + isWorkspaceProtocolReplacementVersion, + satisfies, + isValidRange, +}; diff --git a/src/version.js b/src/version.js index a2a4ad5f..02e6cb48 100644 --- a/src/version.js +++ b/src/version.js @@ -1,13 +1,31 @@ 'use strict'; const semver = require('semver'); +const { + isWorkspaceProtocol, + isWorkspaceProtocolReplacementVersion, + extractRange, +} = require('./semver'); function trackNewVersion({ name, - oldRange, - newRange, + oldRange: _oldRange, + newRange: _newRange, newVersion, }) { + let oldRange = _oldRange; + let newRange = _newRange; + let _isWorkspaceProtocol = isWorkspaceProtocol(_oldRange); + + if (_isWorkspaceProtocol) { + oldRange = extractRange(_oldRange); + newRange = extractRange(_newRange); + + if (isWorkspaceProtocolReplacementVersion(oldRange)) { + return _oldRange; + } + } + let range = new semver.Range(newRange); if (range.set.length > 1) { @@ -41,6 +59,10 @@ function trackNewVersion({ } } + if (_isWorkspaceProtocol) { + newRange = `workspace:${newRange}`; + } + return newRange; } diff --git a/test/defrag-test.js b/test/defrag-test.js index c3255f2f..fcf61557 100644 --- a/test/defrag-test.js +++ b/test/defrag-test.js @@ -173,6 +173,83 @@ describe(function() { expect(rangeUpdates).to.deep.equal({}); }); + it('handles workspace protocol ranges', function() { + let allRanges = { + packageA: [ + 'workspace:^1.0.0', + 'workspace:^1.0.1', + ], + }; + + let rangeUpdates = filterRangeUpdates(allRanges); + + expect(rangeUpdates).to.deep.equal({ + packageA: { + 'workspace:^1.0.0': 'workspace:^1.0.1', + }, + }); + }); + + it('ignores workspace wildcard ranges', function() { + let allRanges = { + packageA: [ + 'workspace:*', + 'workspace:^1.0.1', + ], + }; + + let rangeUpdates = filterRangeUpdates(allRanges); + + expect(rangeUpdates).to.deep.equal({}); + }); + + it('converts workspace protocols while preserving protocol type', function() { + let allRanges = { + packageA: [ + 'workspace:^1.0.0', + '^1.0.1', + ], + }; + + let rangeUpdates = filterRangeUpdates(allRanges); + + expect(rangeUpdates).to.deep.equal({ + packageA: { + 'workspace:^1.0.0': 'workspace:^1.0.1', + }, + }); + }); + + it('updates regular semver based on workspace protocol versions', function() { + let allRanges = { + packageA: [ + '^1.0.0', + 'workspace:^1.0.1', + ], + }; + + let rangeUpdates = filterRangeUpdates(allRanges); + + expect(rangeUpdates).to.deep.equal({ + packageA: { + '^1.0.0': '^1.0.1', + }, + }); + }); + + it('ignores workspace protocol shortcuts', function() { + let allRanges = { + packageA: [ + 'workspace:^', + '^1.0.1', + ], + }; + + let rangeUpdates = filterRangeUpdates(allRanges); + + expect(rangeUpdates).to.deep.equal({}); + }); + it('can include out of range patches', function() { let allRanges = { packageA: [ diff --git a/test/release-test.js b/test/release-test.js index 6f822701..615558f9 100644 --- a/test/release-test.js +++ b/test/release-test.js @@ -1590,6 +1590,27 @@ describe(_release, function() { expect(publishOverride.args[0][0]?.originalPublish).to.be.a('function'); expect(publishOverride.args[0][0]?.distTag).to.equal('latest'); expect(publishOverride.args[0][0]?.dryRun).to.equal(false); + expect(publishOverride.args[0][0]?.isPnpm).to.equal(false); + }); + + it('passes isPnpm=true to publishOverride when pnpm-lock.yaml is present', async function() { + fixturify.writeSync(tmpPath, { + 'pnpm-lock.yaml': '', + }); + + let publishOverride = this.spy(); + + await release({ + shouldPublish: true, + publishOverride, + }); + + expect(publishOverride).to.be.calledOnce; + expect(publishOverride.args[0][0]?.cwd).to.equal(path.join(tmpPath, 'packages/package-a')); + expect(publishOverride.args[0][0]?.originalPublish).to.be.a('function'); + expect(publishOverride.args[0][0]?.distTag).to.equal('latest'); + expect(publishOverride.args[0][0]?.dryRun).to.equal(false); + expect(publishOverride.args[0][0]?.isPnpm).to.equal(true); }); }); @@ -1718,6 +1739,54 @@ describe(_release, function() { expect(pushOverride.args).to.match([[this.match({ dryRun: true })]]); expect(publishOverride.args).to.match([[this.match({ dryRun: true })]]); }); + + it('uses pnpm publish when pnpm-lock.yaml is present', async function() { + fixturify.writeSync(tmpPath, { + 'pnpm-lock.yaml': '', + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: foo'], { cwd: tmpPath }); + + gitCopyPath = await createTmpDir(); + await fs.copy(path.join(tmpPath, '.git'), gitCopyPath); + + let preCommitCallback = this.spy(); + let prePushCallback = this.spy(); + let prePublishCallback = this.spy(); + + await release({ + dryRun, + scripts: { + precommit: 'precommit test', + postcommit: 'postcommit test', + pretag: 'pretag test', + posttag: 'posttag test', + }, + silent: false, + shouldPush: true, + shouldPublish: true, + preCommitCallback, + prePushCallback, + prePublishCallback, + }); + + await assertCleanGit(); + + expect(consoleLog.args).to.deep.equal([ + ['Updating @scope/package-a from 1.0.0-detached to 1.0.0.'], + ['Updating root devDependencies @scope/package-a from 1.0.0 || 1.0.0-detached to 1.1.0.'], + ['precommit test', { cwd: tmpPath, shell: true }], + ['git', ['commit', '-m', "'chore(release): @scope/package-a@1.1.0,root@1.0.1'"], { cwd: tmpPath }], + ['postcommit test', { cwd: tmpPath, shell: true }], + ['pretag test', { cwd: tmpPath, shell: true }], + ['git', ['tag', '-a', '@scope/package-a@1.1.0', '-m', '@scope/package-a@1.1.0'], { cwd: tmpPath }], + ['git', ['tag', '-a', 'root@1.0.1', '-m', 'root@1.0.1'], { cwd: tmpPath }], + ['posttag test', { cwd: tmpPath, shell: true }], + ['git', ['push', '--follow-tags', '--atomic', '--dry-run'], { cwd: tmpPath }], + ['pnpm', ['publish', '--tag', 'latest', '--dry-run'], { cwd: path.join(tmpPath, 'packages/package-a') }], + ]); + }); }); describe('pnpm', function() { @@ -1777,6 +1846,44 @@ describe(_release, function() { expect(updatePnpmLockfile).to.have.been.calledOnce; }); + + it('uses pnpm publish when pnpm-lock.yaml is present', async function() { + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '0.0.0', + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + }), + 'pnpm-lock.yaml': '', + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: foo'], { cwd: tmpPath }); + + let publishOverride = this.spy(); + + await release({ + shouldPublish: true, + publishOverride, + silent: true, + }); + + expect(publishOverride).to.have.been.calledOnceWith(this.match({ + isPnpm: true, + cwd: path.join(tmpPath, 'packages/package-a'), + dryRun: false, + distTag: 'latest', + })); + }); }); describe('yarn', function() { @@ -1837,4 +1944,332 @@ describe(_release, function() { expect(updateYarnLockfile).to.have.been.calledOnce; }); }); + + describe('workspace protocol dependencies', function() { + it('preserves workspace:* dependencies unchanged', async function() { + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:*', + }, + }), + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'init'], { cwd: tmpPath }); + + // Make changes to package-a to trigger a version bump + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + 'index.js': 'console.log("new feature");', + }, + }, + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: add new feature'], { cwd: tmpPath }); + + await release({ + shouldBumpInRangeDependencies: true, + }); + + let workspaces = readWorkspaces(tmpPath); + + expect(workspaces).to.deep.equal({ + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.1.0', + }), + 'index.js': 'console.log("new feature");', + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:*', + }, + }), + }); + }); + + it('updates workspace protocol dependencies while preserving protocol', async function() { + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.0', + 'dependencies': { + '@scope/package-a': 'workspace:^1.0.0', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~1.0.0', + }, + }), + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'init'], { cwd: tmpPath }); + + // Make changes to package-a to trigger a version bump + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + 'index.js': 'console.log("new feature");', + }, + }, + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: add new feature'], { cwd: tmpPath }); + + await release({ + shouldBumpInRangeDependencies: true, + }); + + let workspaces = readWorkspaces(tmpPath); + + expect(workspaces).to.deep.equal({ + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.1.0', + }), + 'index.js': 'console.log("new feature");', + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.1', + 'dependencies': { + '@scope/package-a': 'workspace:^1.1.0', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~1.1.0', + }, + }), + }); + }); + + it('updates workspace protocol dependencies when packages are bumped', async function() { + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.0', + 'dependencies': { + '@scope/package-a': 'workspace:^1.0.0', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~1.0.0', + }, + }), + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'init'], { cwd: tmpPath }); + + // Make changes to package-a to trigger a version bump + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + 'index.js': 'console.log("new feature");', + }, + }, + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: add new feature'], { cwd: tmpPath }); + + await release({ + shouldBumpInRangeDependencies: true, + }); + + let workspaces = readWorkspaces(tmpPath); + + expect(workspaces).to.deep.equal({ + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.1.0', + }), + 'index.js': 'console.log("new feature");', + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.1', + 'dependencies': { + '@scope/package-a': 'workspace:^1.1.0', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~1.1.0', + }, + }), + }); + }); + + it('handles workspace protocol shortcuts correctly', async function() { + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.0', + 'dependencies': { + '@scope/package-a': 'workspace:^', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~', + }, + }), + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'init'], { cwd: tmpPath }); + + // Make changes to package-a to trigger a version bump + fixturify.writeSync(tmpPath, { + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.0.0', + }), + 'index.js': 'console.log("new feature");', + }, + }, + }); + + await execa('git', ['add', '.'], { cwd: tmpPath }); + await execa('git', ['commit', '-m', 'feat: add new feature'], { cwd: tmpPath }); + + await release({ + shouldBumpInRangeDependencies: true, + }); + + let workspaces = readWorkspaces(tmpPath); + + expect(workspaces).to.deep.equal({ + 'packages': { + 'package-a': { + 'package.json': stringifyJson({ + 'name': '@scope/package-a', + 'version': '1.1.0', + }), + 'index.js': 'console.log("new feature");', + }, + 'package-b': { + 'package.json': stringifyJson({ + 'name': '@scope/package-b', + 'version': '1.0.1', + 'dependencies': { + '@scope/package-a': 'workspace:^', + }, + }), + }, + }, + 'package.json': stringifyJson({ + 'private': true, + 'workspaces': [ + 'packages/*', + ], + 'devDependencies': { + '@scope/package-a': 'workspace:~', + }, + }), + }); + }); + }); }); diff --git a/test/semver-test.js b/test/semver-test.js new file mode 100644 index 00000000..4e0e2855 --- /dev/null +++ b/test/semver-test.js @@ -0,0 +1,113 @@ +'use strict'; + +const { describe, it } = require('./helpers/mocha'); +const { expect } = require('./helpers/chai'); +const { + isWorkspaceProtocol, + extractRange, + isWorkspaceProtocolReplacementVersion, + satisfies, + isValidRange, +} = require('../src/semver'); + +describe(function() { + describe(isWorkspaceProtocol, function() { + it('detects workspace protocol ranges', function() { + expect(isWorkspaceProtocol('workspace:*')).to.equal(true); + expect(isWorkspaceProtocol('workspace:^1.0.0')).to.equal(true); + expect(isWorkspaceProtocol('workspace:~1.2.3')).to.equal(true); + expect(isWorkspaceProtocol('workspace:^')).to.equal(true); + expect(isWorkspaceProtocol('workspace:~')).to.equal(true); + }); + + it('returns false for regular semver ranges', function() { + expect(isWorkspaceProtocol('^1.0.0')).to.equal(false); + expect(isWorkspaceProtocol('~1.2.3')).to.equal(false); + expect(isWorkspaceProtocol('*')).to.equal(false); + expect(isWorkspaceProtocol('1.0.0')).to.equal(false); + }); + }); + + describe(extractRange, function() { + it('extracts semver from workspace protocol', function() { + expect(extractRange('workspace:^1.0.0')).to.equal('^1.0.0'); + expect(extractRange('workspace:~1.2.3')).to.equal('~1.2.3'); + expect(extractRange('workspace:1.0.0')).to.equal('1.0.0'); + expect(extractRange('workspace:*')).to.equal('*'); + }); + + it('returns regular semver unchanged', function() { + expect(extractRange('^1.0.0')).to.equal('^1.0.0'); + expect(extractRange('~1.2.3')).to.equal('~1.2.3'); + expect(extractRange('*')).to.equal('*'); + }); + + it('handles special workspace shortcuts', function() { + expect(extractRange('workspace:')).to.equal(''); + expect(extractRange('workspace:^')).to.equal('^'); + expect(extractRange('workspace:~')).to.equal('~'); + }); + }); + + describe(isWorkspaceProtocolReplacementVersion, function() { + it('detects replacement version shortcuts', function() { + expect(isWorkspaceProtocolReplacementVersion('^')).to.equal(true); + expect(isWorkspaceProtocolReplacementVersion('~')).to.equal(true); + }); + + it('returns false for non-replacement versions', function() { + expect(isWorkspaceProtocolReplacementVersion('*')).to.equal(false); + expect(isWorkspaceProtocolReplacementVersion('')).to.equal(false); + expect(isWorkspaceProtocolReplacementVersion('^1.0.0')).to.equal(false); + expect(isWorkspaceProtocolReplacementVersion('~1.2.3')).to.equal(false); + expect(isWorkspaceProtocolReplacementVersion('1.0.0')).to.equal(false); + }); + }); + + describe(satisfies, function() { + it('handles regular semver ranges', function() { + expect(satisfies('1.5.0', '^1.0.0')).to.equal(true); + expect(satisfies('2.0.0', '^1.0.0')).to.equal(false); + expect(satisfies('1.2.4', '~1.2.3')).to.equal(true); + expect(satisfies('1.3.0', '~1.2.3')).to.equal(false); + }); + + it('handles workspace protocol ranges transparently', function() { + expect(satisfies('1.5.0', 'workspace:^1.0.0')).to.equal(true); + expect(satisfies('2.0.0', 'workspace:^1.0.0')).to.equal(false); + expect(satisfies('1.2.4', 'workspace:~1.2.3')).to.equal(true); + expect(satisfies('1.3.0', 'workspace:~1.2.3')).to.equal(false); + }); + + it('handles special workspace shortcuts', function() { + expect(satisfies('1.0.0', 'workspace:*')).to.equal(true); + expect(satisfies('999.999.999', 'workspace:*')).to.equal(true); + expect(satisfies('1.0.0', 'workspace:^')).to.equal(true); + expect(satisfies('1.0.0', 'workspace:~')).to.equal(true); + }); + }); + + describe(isValidRange, function() { + it('validates regular semver ranges', function() { + expect(isValidRange('^1.0.0')).to.be.ok; + expect(isValidRange('~1.2.3')).to.be.ok; + expect(isValidRange('1.0.0')).to.be.ok; + expect(isValidRange('*')).to.be.ok; + expect(isValidRange('invalid')).to.be.null; + }); + + it('validates workspace protocol ranges transparently', function() { + expect(isValidRange('workspace:^1.0.0')).to.be.ok; + expect(isValidRange('workspace:~1.2.3')).to.be.ok; + expect(isValidRange('workspace:1.0.0')).to.be.ok; + expect(isValidRange('workspace:*')).to.be.ok; + expect(isValidRange('workspace:^')).to.equal(true); + expect(isValidRange('workspace:~')).to.equal(true); + expect(isValidRange('workspace:invalid')).to.be.null; + }); + + it('rejects invalid inputs', function() { + expect(isValidRange('invalid')).to.be.null; + }); + }); +}); diff --git a/test/version-test.js b/test/version-test.js index 01ba15ed..963ceeaa 100644 --- a/test/version-test.js +++ b/test/version-test.js @@ -134,5 +134,27 @@ describe(function() { expect(warn.withArgs(warning)).to.be.calledOnce; }); + + describe('workspace protocol support', function() { + it('preserves workspace protocol replacement version shortcuts', function() { + let oldRange = 'workspace:^'; + let newRange = '^2.0.0'; + let newVersion = '2.0.0'; + + let result = trackNewVersion({ name, oldRange, newRange, newVersion }); + + expect(result).to.equal('workspace:^'); + }); + + it('tracks workspace protocol major versions with ^', function() { + let oldRange = 'workspace:^1.0.0'; + let newRange = '^2.0.0'; + let newVersion = '2.0.0'; + + let result = trackNewVersion({ name, oldRange, newRange, newVersion }); + + expect(result).to.equal('workspace:^2.0.0'); + }); + }); }); });