diff --git a/.eslintrc b/.eslintrc index ea182e6c..03db2457 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,7 +16,8 @@ { "files": ["*.ts", "*.tsx"], "parserOptions": { - "project": "./{src,test}/tsconfig.json" + "project": "./{src,test}/tsconfig.json", + "EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true }, "extends": [ "plugin:@typescript-eslint/recommended", diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 510b3ec7..15209531 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -11,18 +11,26 @@ import { NpmRegistry, officialNpmRegistryUrl, uriToIdentifier } from '../utils/n const { gt, coerce } = semver; +type RequiredRegistryApi = Pick; + export interface UpgradeOptions { directoryPath: string; dryRun?: boolean; registryUrl?: string; + createRegistry?: (url: string, token?: string) => RequiredRegistryApi; log?: (message: unknown) => void; logError?: (message: unknown) => void; } +function createDefaultRegistry(url: string, token?: string) { + return new NpmRegistry(url, token); +} + export async function upgrade({ directoryPath, registryUrl, dryRun, + createRegistry = createDefaultRegistry, log = console.log, logError = console.error, }: UpgradeOptions): Promise { @@ -33,7 +41,7 @@ export async function upgrade({ const npmConfig = await loadEnvNpmConfig({ basePath: directoryPath }); const resolvedRegistryUrl = registryUrl ?? npmConfig['registry'] ?? officialNpmRegistryUrl; const token = npmConfig[`${uriToIdentifier(resolvedRegistryUrl)}:_authToken`]; - const registry = new NpmRegistry(resolvedRegistryUrl, token); + const registry = createRegistry(resolvedRegistryUrl, token); const internalPackageNames = new Set(packages.map(({ packageJson }) => packageJson.name!)); @@ -149,7 +157,7 @@ export async function upgrade({ } export interface IFetchLatestPackageVersionsOptions { - registry: NpmRegistry; + registry: RequiredRegistryApi; packageNames: Set; logError: (message: unknown) => void; } diff --git a/test/fixtures/dependencies-exact/package.json b/test/fixtures/dependencies-exact/package.json new file mode 100644 index 00000000..c6048add --- /dev/null +++ b/test/fixtures/dependencies-exact/package.json @@ -0,0 +1,8 @@ +{ + "name": "pleb-dependencies-exact", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "package-a": "1.0.0" + } +} diff --git a/test/fixtures/dependencies-file-ref/inner-dep/package.json b/test/fixtures/dependencies-file-ref/inner-dep/package.json new file mode 100644 index 00000000..66064ba3 --- /dev/null +++ b/test/fixtures/dependencies-file-ref/inner-dep/package.json @@ -0,0 +1,8 @@ +{ + "name": "pleb-dependencies-file-ref-inner", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "package-b": "^1.0.0" + } +} diff --git a/test/fixtures/dependencies-file-ref/package.json b/test/fixtures/dependencies-file-ref/package.json new file mode 100644 index 00000000..b35f1d63 --- /dev/null +++ b/test/fixtures/dependencies-file-ref/package.json @@ -0,0 +1,8 @@ +{ + "name": "pleb-dependencies-file-ref", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "package-a": "file:./inner-dep" + } +} diff --git a/test/fixtures/dependencies-pinned/package.json b/test/fixtures/dependencies-pinned/package.json new file mode 100644 index 00000000..b7f8bbf2 --- /dev/null +++ b/test/fixtures/dependencies-pinned/package.json @@ -0,0 +1,9 @@ +{ + "name": "pleb-dependencies", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "package-a": "~1.0.0", + "package-b": "~1.0.0" + } +} diff --git a/test/fixtures/dependencies-pinned/pleb.config.mjs b/test/fixtures/dependencies-pinned/pleb.config.mjs new file mode 100644 index 00000000..60bba8c6 --- /dev/null +++ b/test/fixtures/dependencies-pinned/pleb.config.mjs @@ -0,0 +1,3 @@ +export default { + pinnedPackages: [{ name: 'package-b', reason: 'broken stuff' }], +}; diff --git a/test/fixtures/dependencies/package.json b/test/fixtures/dependencies/package.json new file mode 100644 index 00000000..d54369fe --- /dev/null +++ b/test/fixtures/dependencies/package.json @@ -0,0 +1,10 @@ +{ + "name": "pleb-dependencies", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "package-a": "~1.0.0", + "package-b": "~1.0.0", + "package-c": "^1.0.0" + } +} diff --git a/test/fixtures/lerna-workspace/packages/a/package.json b/test/fixtures/lerna-workspace/packages/a/package.json index 56341e1c..7eace803 100644 --- a/test/fixtures/lerna-workspace/packages/a/package.json +++ b/test/fixtures/lerna-workspace/packages/a/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "scripts": {}, "dependencies": { - "lerna-workspace-b": "^1.0.0" + "lerna-workspace-b": "^1.0.0", + "external-a": "^1.0.0" } } diff --git a/test/fixtures/lerna-workspace/packages/b/package.json b/test/fixtures/lerna-workspace/packages/b/package.json index 79ae91db..a6d136d7 100644 --- a/test/fixtures/lerna-workspace/packages/b/package.json +++ b/test/fixtures/lerna-workspace/packages/b/package.json @@ -1,5 +1,8 @@ { "name": "lerna-workspace-b", "version": "1.0.0", - "scripts": {} + "scripts": {}, + "dependencies": { + "external-b": "^1.0.0" + } } diff --git a/test/fixtures/yarn-workspace/packages/a/package.json b/test/fixtures/yarn-workspace/packages/a/package.json index 3dca64f4..6b06e1fa 100644 --- a/test/fixtures/yarn-workspace/packages/a/package.json +++ b/test/fixtures/yarn-workspace/packages/a/package.json @@ -5,6 +5,7 @@ "prepack": "echo \"prepack yarn-workspace-a\"" }, "dependencies": { - "yarn-workspace-b": "^1.0.0" + "yarn-workspace-b": "^1.0.0", + "external-a": "^1.0.0" } } diff --git a/test/fixtures/yarn-workspace/packages/b/package.json b/test/fixtures/yarn-workspace/packages/b/package.json index 38913ef3..c7c9c130 100644 --- a/test/fixtures/yarn-workspace/packages/b/package.json +++ b/test/fixtures/yarn-workspace/packages/b/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "scripts": { "prepack": "echo \"prepack yarn-workspace-b\"" + }, + "dependencies": { + "external-b": "^1.0.0" } } diff --git a/test/tsconfig.json b/test/tsconfig.json index 57938b44..c4589e81 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -2,5 +2,6 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "../dist/test" - } + }, + "references": [{ "path": "../src" }] } diff --git a/test/upgrade.test.ts b/test/upgrade.test.ts new file mode 100644 index 00000000..bea7ddbd --- /dev/null +++ b/test/upgrade.test.ts @@ -0,0 +1,226 @@ +import { describe, it } from 'node:test'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { upgrade } from 'pleb'; + +const fixturesRoot = fileURLToPath(new URL('../../test/fixtures', import.meta.url)); + +describe('pleb upgrade', () => { + function createMockRegistry(packagesData: Record) { + return { + async fetchDistTags(packageName: string) { + return Promise.resolve(packagesData[packageName]!); + }, + dispose() { + /**/ + }, + }; + } + + function createMockOutput() { + const output: { type: 'log' | 'error'; message: unknown }[] = []; + return { + output, + log(this: void, message: unknown) { + output.push({ type: 'log', message }); + }, + logError(this: void, message: unknown) { + output.push({ type: 'error', message }); + }, + }; + } + + it('should report nothing to upgrade for an empty project', async () => { + const newPackagePath = join(fixturesRoot, 'new-package'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => createMockRegistry({}), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 0 dependencies...' }, + { type: 'log', message: 'Nothing to upgrade.' }, + ]); + }); + + it('should report nothing to upgrade when everything is up to date', async () => { + const newPackagePath = join(fixturesRoot, 'dependencies'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'package-a': { latest: '1.0.0' }, + 'package-b': { latest: '1.0.0' }, + 'package-c': { latest: '1.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 3 dependencies...' }, + { type: 'log', message: 'Nothing to upgrade.' }, + ]); + }); + + it('should report on dependencies with newer versions', async () => { + const newPackagePath = join(fixturesRoot, 'dependencies'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'package-a': { latest: '1.0.0' }, + 'package-b': { latest: '2.0.0' }, + 'package-c': { latest: '2.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 3 dependencies...' }, + { type: 'log', message: 'Changes:' }, + { type: 'log', message: ' package-b ~1.0.0 -> ~2.0.0' }, + { type: 'log', message: ' package-c ^1.0.0 -> ^2.0.0' }, + ]); + }); + + it('should force exact version to caret (opinionated)', async () => { + const newPackagePath = join(fixturesRoot, 'dependencies-exact'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'package-a': { latest: '1.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 1 dependencies...' }, + { type: 'log', message: 'Changes:' }, + { type: 'log', message: ' package-a 1.0.0 -> ^1.0.0' }, + ]); + }); + + it('should not update file reference', async () => { + const newPackagePath = join(fixturesRoot, 'dependencies-file-ref'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'package-a': { latest: '2.0.0' }, + 'package-b': { latest: '1.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 1 dependencies...' }, + { type: 'log', message: 'Nothing to upgrade.' }, + ]); + }); + + it('should report from multiple yarn workspaces (only on external packages)', async () => { + const newPackagePath = join(fixturesRoot, 'yarn-workspace'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'yarn-workspace-b': { latest: '2.0.0' }, + 'external-a': { latest: '2.0.0' }, + 'external-b': { latest: '2.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 2 dependencies...' }, + { type: 'log', message: 'Changes:' }, + { type: 'log', message: ' external-b ^1.0.0 -> ^2.0.0' }, + { type: 'log', message: ' external-a ^1.0.0 -> ^2.0.0' }, + ]); + }); + + it('should report from multiple lerna workspaces (only on external packages)', async () => { + const newPackagePath = join(fixturesRoot, 'lerna-workspace'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'lerna-workspace-b': { latest: '2.0.0' }, + 'external-a': { latest: '2.0.0' }, + 'external-b': { latest: '2.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 2 dependencies...' }, + { type: 'log', message: 'Changes:' }, + { type: 'log', message: ' external-b ^1.0.0 -> ^2.0.0' }, + { type: 'log', message: ' external-a ^1.0.0 -> ^2.0.0' }, + ]); + }); + + it('should skip pinned packages', async () => { + const newPackagePath = join(fixturesRoot, 'dependencies-pinned'); + const { log, logError, output } = createMockOutput(); + + await upgrade({ + dryRun: true, + directoryPath: newPackagePath, + registryUrl: '', + createRegistry: () => + createMockRegistry({ + 'package-a': { latest: '2.0.0' }, + 'package-b': { latest: '2.0.0' }, + }), + log, + logError, + }); + + assert.deepEqual(output, [ + { type: 'log', message: 'Getting "latest" version for 2 dependencies...' }, + { type: 'log', message: 'Changes:' }, + { type: 'log', message: ' package-a ~1.0.0 -> ~2.0.0' }, + { type: 'log', message: 'Skipped:' }, + { type: 'log', message: ' package-b ~1.0.0 -> ~2.0.0 (broken stuff)' }, + ]); + }); +});