From 982896d5d10fe65886ef42bf987468ff649d5c91 Mon Sep 17 00:00:00 2001 From: Sergio Zharinov <zharinov@users.noreply.github.com> Date: Fri, 4 Oct 2019 11:13:14 +0400 Subject: [PATCH] feat: Elixir support (#4496) --- Dockerfile | 30 ++++ docs/usage/configuration-options.md | 13 ++ lib/config/definitions.ts | 13 ++ lib/datasource/hex/index.ts | 97 ++++++------ lib/manager/index.ts | 2 + lib/manager/mix/artifacts.ts | 105 +++++++++++++ lib/manager/mix/extract.ts | 66 +++++++++ lib/manager/mix/index.ts | 5 + lib/manager/mix/readme.md | 140 ++++++++++++++++++ lib/manager/mix/update.ts | 24 +++ lib/versioning/hex/index.ts | 10 +- lib/versioning/hex/readme.md | 4 +- renovate-schema.json | 10 ++ .../__snapshots__/validation.spec.ts.snap | 2 +- test/datasource/hex.spec.ts | 27 +++- .../mix/__snapshots__/artifacts.spec.ts.snap | 23 +++ .../mix/__snapshots__/extract.spec.ts.snap | 75 ++++++++++ test/manager/mix/_fixtures/mix.exs | 29 ++++ test/manager/mix/artifacts.spec.ts | 71 +++++++++ test/manager/mix/extract.spec.ts | 20 +++ test/manager/mix/update.spec.ts | 50 +++++++ test/versioning/hex.spec.ts | 83 ++++++----- .../extract/__snapshots__/index.spec.js.snap | 3 + 23 files changed, 813 insertions(+), 89 deletions(-) create mode 100644 lib/manager/mix/artifacts.ts create mode 100644 lib/manager/mix/extract.ts create mode 100644 lib/manager/mix/index.ts create mode 100644 lib/manager/mix/readme.md create mode 100644 lib/manager/mix/update.ts create mode 100644 test/manager/mix/__snapshots__/artifacts.spec.ts.snap create mode 100644 test/manager/mix/__snapshots__/extract.spec.ts.snap create mode 100644 test/manager/mix/_fixtures/mix.exs create mode 100644 test/manager/mix/artifacts.spec.ts create mode 100644 test/manager/mix/extract.spec.ts create mode 100644 test/manager/mix/update.spec.ts diff --git a/Dockerfile b/Dockerfile index c6974efbb1..23babc89cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,31 @@ RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ ## END copy Node.js +# Erlang + +RUN cd /tmp && \ + curl https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb -o erlang-solutions_1.0_all.deb && \ + dpkg -i erlang-solutions_1.0_all.deb && \ + rm -f erlang-solutions_1.0_all.deb + +ENV ERLANG_VERSION=22.0.2-1 + +RUN apt-get update && \ + apt-cache policy esl-erlang && \ + apt-get install -y esl-erlang=1:$ERLANG_VERSION && \ + apt-get clean + +# Elixir + +ENV ELIXIR_VERSION 1.8.2 + +RUN curl -L https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/Precompiled.zip -o Precompiled.zip && \ + mkdir -p /opt/elixir-${ELIXIR_VERSION}/ && \ + unzip Precompiled.zip -d /opt/elixir-${ELIXIR_VERSION}/ && \ + rm Precompiled.zip + +ENV PATH $PATH:/opt/elixir-${ELIXIR_VERSION}/bin + # PHP Composer RUN apt-get update && apt-get install -y php-cli php-mbstring && apt-get clean @@ -143,6 +168,11 @@ RUN set -ex ;\ curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain none -y ; \ rustup toolchain install 1.36.0 +# Mix and Rebar + +RUN mix local.hex --force +RUN mix local.rebar --force + # Pipenv ENV PATH="/home/ubuntu/.local/bin:$PATH" diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 033828b269..2fc98c87d7 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -552,6 +552,19 @@ Set enabled to `true` to enable meteor package updating. Add to this object if you wish to define rules that apply only to minor updates. +## mix + +Elixir support is in beta stage. +It should be explicitly enabled in configuration file: + +```json +{ + "mix": { + "enabled": true + } +} +``` + ## node Using this configuration option allows you to apply common configuration and policies across all Node.js version updates even if managed by different package managers (`npm`, `yarn`, etc.). diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index e69d38c85f..182ac2af95 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -1421,6 +1421,19 @@ const options: RenovateOptions[] = [ }, mergeable: true, }, + { + name: 'mix', + releaseStatus: 'beta', + description: 'Configuration object for Mix module renovation', + stage: 'repository', + type: 'object', + default: { + enabled: false, + fileMatch: ['(^|/)mix\\.exs$'], + versionScheme: 'hex', + }, + mergeable: true, + }, { name: 'rust', releaseStatus: 'unpublished', diff --git a/lib/datasource/hex/index.ts b/lib/datasource/hex/index.ts index 2ad134f203..85cfa46d4f 100644 --- a/lib/datasource/hex/index.ts +++ b/lib/datasource/hex/index.ts @@ -2,69 +2,80 @@ import { logger } from '../../logger'; import got from '../../util/got'; import { ReleaseResult, PkgReleaseConfig } from '../common'; -function getHostOpts() { - return { - json: true, - hostType: 'hex', - }; -} - interface HexRelease { html_url: string; meta?: { links?: Record<string, string> }; - name?: string; releases?: { version: string }[]; } export async function getPkgReleases({ lookupName, }: Partial<PkgReleaseConfig>): Promise<ReleaseResult | null> { - const hexUrl = `https://hex.pm/api/packages/${lookupName}`; + // istanbul ignore if + if (!lookupName) { + logger.warn('hex lookup failure: No lookupName'); + return null; + } + + // Get dependency name from lookupName. + // If the dependency is private lookupName contains organization name as following: + // depName:organizationName + // depName is used to pass it in hex dep url + // organizationName is used for accessing to private deps + const depName = lookupName.split(':')[0]; + const hexUrl = `https://hex.pm/api/packages/${depName}`; try { - const opts = getHostOpts(); - const res: HexRelease = (await got(hexUrl, { + const response = await got(hexUrl, { json: true, - ...opts, - })).body; - if (!(res && res.releases && res.name)) { - logger.warn({ lookupName }, `Received invalid hex package data`); + hostType: 'hex', + }); + + const hexRelease: HexRelease = response.body; + + if (!hexRelease) { + logger.warn({ depName }, `Invalid response body`); + return null; + } + + const { releases = [], html_url: homepage, meta } = hexRelease; + + if (releases.length === 0) { + logger.info(`No versions found for ${depName} (${hexUrl})`); // prettier-ignore return null; } + const result: ReleaseResult = { - releases: [], + releases: releases.map(({ version }) => ({ version })), }; - if (res.releases) { - result.releases = res.releases.map(version => ({ - version: version.version, - })); + + if (homepage) { + result.homepage = homepage; } - if (res.meta && res.meta.links) { - result.sourceUrl = res.meta.links.Github; + + if (meta && meta.links && meta.links.Github) { + result.sourceUrl = hexRelease.meta.links.Github; } - result.homepage = res.html_url; + return result; } catch (err) { - if (err.statusCode === 401) { - logger.info({ lookupName }, `Authorization failure: not authorized`); - logger.debug( - { - err, - }, - 'Authorization error' - ); - return null; + const errorData = { depName, err }; + + if ( + err.statusCode === 429 || + (err.statusCode >= 500 && err.statusCode < 600) + ) { + logger.warn({ lookupName, err }, `hex.pm registry failure`); + throw new Error('registry-failure'); } - if (err.statusCode === 404) { - logger.info({ lookupName }, `Dependency lookup failure: not found`); - logger.debug( - { - err, - }, - 'Package lookup error' - ); - return null; + + if (err.statusCode === 401) { + logger.debug(errorData, 'Authorization error'); + } else if (err.statusCode === 404) { + logger.debug(errorData, 'Package lookup error'); + } else { + logger.warn(errorData, 'hex lookup failure: Unknown error'); } - logger.warn({ err, lookupName }, 'hex lookup failure: Unknown error'); - return null; } + + return null; } diff --git a/lib/manager/index.ts b/lib/manager/index.ts index d6f49eff05..6e02db23e9 100644 --- a/lib/manager/index.ts +++ b/lib/manager/index.ts @@ -32,6 +32,7 @@ const managerList = [ 'leiningen', 'maven', 'meteor', + 'mix', 'npm', 'nuget', 'nvm', @@ -56,6 +57,7 @@ const languageList = [ 'dart', 'docker', 'dotnet', + 'elixir', 'golang', 'js', 'node', diff --git a/lib/manager/mix/artifacts.ts b/lib/manager/mix/artifacts.ts new file mode 100644 index 0000000000..7728a5c402 --- /dev/null +++ b/lib/manager/mix/artifacts.ts @@ -0,0 +1,105 @@ +import upath from 'upath'; +import fs from 'fs-extra'; +import { hrtime } from 'process'; +import { platform } from '../../platform'; +import { exec } from '../../util/exec'; +import { logger } from '../../logger'; +import { UpdateArtifactsConfig, UpdateArtifactsResult } from '../common'; + +export async function updateArtifacts( + packageFileName: string, + updatedDeps: string[], + newPackageFileContent: string, + config: UpdateArtifactsConfig +): Promise<UpdateArtifactsResult[] | null> { + await logger.debug(`mix.getArtifacts(${packageFileName})`); + if (updatedDeps.length < 1) { + logger.debug('No updated mix deps - returning null'); + return null; + } + + const cwd = config.localDir; + if (!cwd) { + logger.debug('No local dir specified'); + return null; + } + + const lockFileName = 'mix.lock'; + try { + const localPackageFileName = upath.join(cwd, packageFileName); + await fs.outputFile(localPackageFileName, newPackageFileContent); + } catch (err) { + logger.warn({ err }, 'mix.exs could not be written'); + return [ + { + lockFileError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } + + const existingLockFileContent = await platform.getFile(lockFileName); + if (!existingLockFileContent) { + logger.debug('No mix.lock found'); + return null; + } + + const cmdParts = + config.binarySource === 'docker' + ? [ + 'docker', + 'run', + '--rm', + `-v ${cwd}:${cwd}`, + `-w ${cwd}`, + 'renovate/mix mix', + ] + : ['mix']; + cmdParts.push('deps.update'); + + const startTime = hrtime(); + /* istanbul ignore next */ + try { + const command = [...cmdParts, ...updatedDeps].join(' '); + const { stdout, stderr } = await exec(command, { cwd }); + logger.debug(stdout); + if (stderr) logger.warn('error: ' + stderr); + } catch (err) { + logger.warn( + { err, message: err.message }, + 'Failed to update Mix lock file' + ); + + return [ + { + lockFileError: { + lockFile: lockFileName, + stderr: err.message, + }, + }, + ]; + } + + const duration = hrtime(startTime); + const seconds = Math.round(duration[0] + duration[1] / 1e9); + logger.info({ seconds, type: 'mix.lock' }, 'Updated lockfile'); + logger.debug('Returning updated mix.lock'); + + const localLockFileName = upath.join(cwd, lockFileName); + const newMixLockContent = await fs.readFile(localLockFileName, 'utf8'); + if (existingLockFileContent === newMixLockContent) { + logger.debug('mix.lock is unchanged'); + return null; + } + + return [ + { + file: { + name: lockFileName, + contents: newMixLockContent, + }, + }, + ]; +} diff --git a/lib/manager/mix/extract.ts b/lib/manager/mix/extract.ts new file mode 100644 index 0000000000..2fa61084c3 --- /dev/null +++ b/lib/manager/mix/extract.ts @@ -0,0 +1,66 @@ +import { isValid } from '../../versioning/hex'; +import { logger } from '../../logger'; +import { PackageDependency, PackageFile } from '../common'; + +const depSectionRegExp = /defp\s+deps.*do/g; +const depMatchRegExp = /{:(\w+),\s*([^:"]+)?:?\s*"([^"]+)",?\s*(organization: "(.*)")?.*}/gm; + +export function extractPackageFile(content: string): PackageFile { + logger.trace('mix.extractPackageFile()'); + const deps: PackageDependency[] = []; + const contentArr = content.split('\n'); + + for (let lineNumber = 0; lineNumber < contentArr.length; lineNumber += 1) { + if (contentArr[lineNumber].match(depSectionRegExp)) { + logger.trace(`Matched dep section on line ${lineNumber}`); + let depBuffer = ''; + do { + depBuffer += contentArr[lineNumber] + '\n'; + lineNumber += 1; + } while (!contentArr[lineNumber].includes('end')); + let depMatch: RegExpMatchArray; + do { + depMatch = depMatchRegExp.exec(depBuffer); + if (depMatch) { + const depName = depMatch[1]; + const datasource = depMatch[2]; + const currentValue = depMatch[3]; + const organization = depMatch[5]; + + const dep: PackageDependency = { + depName, + currentValue, + managerData: {}, + }; + + dep.datasource = datasource || 'hex'; + + if (dep.datasource === 'hex') { + dep.currentValue = currentValue; + dep.lookupName = depName; + } + + if (organization) { + dep.lookupName += ':' + organization; + } + + if (!isValid(currentValue)) { + dep.skipReason = 'unsupported-version'; + } + + if (dep.datasource !== 'hex') { + dep.skipReason = 'non-hex depTypes'; + } + + // Find dep's line number + for (let i = 0; i < contentArr.length; i += 1) + if (contentArr[i].includes(`:${depName},`)) + dep.managerData.lineNumber = i; + + deps.push(dep); + } + } while (depMatch); + } + } + return { deps }; +} diff --git a/lib/manager/mix/index.ts b/lib/manager/mix/index.ts new file mode 100644 index 0000000000..ed11a06c48 --- /dev/null +++ b/lib/manager/mix/index.ts @@ -0,0 +1,5 @@ +export { extractPackageFile } from './extract'; +export { updateDependency } from './update'; +export { updateArtifacts } from './artifacts'; + +export const language = 'elixir'; diff --git a/lib/manager/mix/readme.md b/lib/manager/mix/readme.md new file mode 100644 index 0000000000..9842682168 --- /dev/null +++ b/lib/manager/mix/readme.md @@ -0,0 +1,140 @@ +## Overview + +#### Name of package manager + +Mix + +#### Implementation status + +Implemented + +#### What language does this support? + +Elixir + +#### Does that language have other (competing?) package managers? + +Mix is main Elixir build tool + +## Package File Detection + +#### What type of package files and names does it use? + +mix.exs + +#### What [fileMatch](https://docs.renovatebot.com/configuration-options/#filematch) pattern(s) should be used? + +File names are static + +#### Is it likely that many users would need to extend this pattern for custom file names? + +No, file names are static + +#### Is the fileMatch pattern likely to get many "false hits" for files that have nothing to do with package management? + +No + +## Parsing and Extraction + +#### Can package files have "local" links to each other that need to be resolved? + +No + +#### Is there reason why package files need to be parsed together (in serial) instead of independently? + +No + +#### What format/syntax is the package file in? e.g. JSON, TOML, custom? + +Custom + +#### How do you suggest parsing the file? Using an off-the-shelf parser, using regex, or can it be custom-parsed line by line? + +RegExp + +#### Does the package file structure distinguish between different "types" of dependencies? e.g. production dependencies, dev dependencies, etc? + +No + +#### List all the sources/syntaxes of dependencies that can be extracted: + + ``` + defp deps() do + [ + {:ecto, "~> 2.0"}, + {:postgrex, "~> 0.8.1"}, + {:cowboy, github: "ninenines/cowboy"}, + ] + ``` + +#### Describe which types of dependencies above are supported and which will be implemented in future: + +All that are mentioned + +## Versioning + +#### What versioning scheme do the package files use? + +SemVer 2.0 schema + +#### Does this versioning scheme support range constraints, e.g. `^1.0.0` or `1.x`? + +Yes, ([doc link](https://hexdocs.pm/elixir/Version.html)) + +#### Is this package manager used for applications, libraries, or both? If both, is there a way to tell which is which? + +There are only modules that can be used as apps and libs + +#### If ranges are supported, are there any cases when Renovate should pin ranges to exact versions if rangeStrategy=auto? + +Supported following ranges of hex datasource: + +- ~> 1.0.0/ ~>1.0 +- and/or ranges + +## Lookup + +#### Is a new datasource required? Provide details + +Implemented ([here]https://github.com/renovatebot/renovate/issues/3043()) + +#### Will users need the capability to specify a custom host/registry to look up? Can it be found within the package files, or within other files inside the repository, or would it require Renovate configuration? + +Mix supports dependencies hosted as git repositories: + +``` + {:cowboy, github: "ninenines/cowboy"}, +``` + +#### Do the package files contain any "constraints" on the parent language (e.g. supports only v3.x of Python) or platform (Linux, Windows, etc) that should be used in the lookup procedure? + +No + +#### Will users need the ability to configure language or other constraints using Renovate config? + +No + +## Artifacts + +#### Are lock files or checksum files used? Mandatory? + +Lock files are mix.lock and they are mandatory + +#### If so, what tool and exact commands should be used if updating 1 or more package versions in a dependency file? + +([mix deps.update](https://hexdocs.pm/mix/master/Mix.Tasks.Deps.Update.html)) + +#### If applicable, describe how the tool maintains a cache and if it can be controlled via CLI or env? Do you recommend the cache be kept or disabled/ignored? + +Mix doesn't cache deps only put compiled files via mix deps.compile. Build files can be deleted via mix deps.clean --build + +#### If applicable, what command should be used to generate a lock file from scratch if you already have a package file? This will be used for "lock file maintenance". + +mix deps.get - gets dependencies and creates/updates .lock file +mix deps.uplock (--all) - deletes dependency in .lock file + +## Other + +Pm files can contain comments that can make a line with dep ignored + +#### Is there anything else to know about this package manager? diff --git a/lib/manager/mix/update.ts b/lib/manager/mix/update.ts new file mode 100644 index 0000000000..c4922fb0ee --- /dev/null +++ b/lib/manager/mix/update.ts @@ -0,0 +1,24 @@ +import { logger } from '../../logger'; +import { Upgrade } from '../common'; + +export function updateDependency( + fileContent: string, + upgrade: Upgrade +): string | null { + logger.debug(`mix.updateDependency: ${upgrade.newValue}`); + + const lines = fileContent.split('\n'); + const lineToChange = lines[upgrade.managerData.lineNumber]; + + if (!lineToChange.includes(upgrade.depName)) return null; + + const newLine = lineToChange.replace(/"(.*?)"/, `"${upgrade.newValue}"`); + + if (newLine === lineToChange) { + logger.debug('No changes necessary'); + return fileContent; + } + + lines[upgrade.managerData.lineNumber] = newLine; + return lines.join('\n'); +} diff --git a/lib/versioning/hex/index.ts b/lib/versioning/hex/index.ts index 1af0860fe2..a6e396fb93 100644 --- a/lib/versioning/hex/index.ts +++ b/lib/versioning/hex/index.ts @@ -58,16 +58,20 @@ const getNewValue = ( toVersion ); newSemver = npm2hex(newSemver); - if (currentValue.match(/~>\s*(\d+\.\d+)$/)) + if (currentValue.match(/~>\s*(\d+\.\d+)$/)) { newSemver = newSemver.replace( - /\^\s*(\d+\.\d+)/, + /\^\s*(\d+\.\d+(\.\d)?)/, (_str, p1) => '~> ' + p1.slice(0, -2) ); - else newSemver = newSemver.replace(/~\s*(\d+\.\d+\.\d)/, '~> $1'); + } else { + newSemver = newSemver.replace(/~\s*(\d+\.\d+\.\d)/, '~> $1'); + } return newSemver; }; +export { isValid }; + export const api: VersioningApi = { ...npm, isLessThanRange, diff --git a/lib/versioning/hex/readme.md b/lib/versioning/hex/readme.md index 4e496b8f0f..23ee8f6eba 100644 --- a/lib/versioning/hex/readme.md +++ b/lib/versioning/hex/readme.md @@ -8,11 +8,11 @@ This versioning syntax is used for Elixir and Erlang hex dependencies ## What type of versioning is used? -Hex versions are base on [Semantic Versioning 2.0](https://semver.org) +Hex versions are based on [Semantic Versioning 2.0](https://semver.org) ## Are ranges supported? How? -Hex supports a subset of npm's range syntax. +Hex supports a [subset of npm range syntax](https://hexdocs.pm/elixir/Version.html). ## Range Strategy support diff --git a/renovate-schema.json b/renovate-schema.json index db9cb98a95..a79b70fa12 100644 --- a/renovate-schema.json +++ b/renovate-schema.json @@ -916,6 +916,16 @@ }, "$ref": "#" }, + "mix": { + "description": "Configuration object for Mix module renovation", + "type": "object", + "default": { + "enabled": false, + "fileMatch": ["(^|/)mix\\.exs$"], + "versionScheme": "hex" + }, + "$ref": "#" + }, "rust": { "description": "Configuration option for Rust package management.", "type": "object", diff --git a/test/config/__snapshots__/validation.spec.ts.snap b/test/config/__snapshots__/validation.spec.ts.snap index 811d4ee9dc..3fa102606a 100644 --- a/test/config/__snapshots__/validation.spec.ts.snap +++ b/test/config/__snapshots__/validation.spec.ts.snap @@ -87,7 +87,7 @@ Array [ "depName": "Configuration Error", "message": "packageRules: You have included an unsupported manager in a package rule. Your list: foo. - Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, homebrew, kubernetes, leiningen, maven, meteor, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).", + Supported managers are: (ansible, bazel, buildkite, bundler, cargo, circleci, composer, deps-edn, docker-compose, dockerfile, droneci, github-actions, gitlabci, gitlabci-include, gomod, gradle, gradle-wrapper, homebrew, kubernetes, leiningen, maven, meteor, mix, npm, nuget, nvm, pip_requirements, pip_setup, pipenv, poetry, pub, sbt, swift, terraform, travis, ruby-version).", }, ] `; diff --git a/test/datasource/hex.spec.ts b/test/datasource/hex.spec.ts index cb5aacc3a7..60fed3c182 100644 --- a/test/datasource/hex.spec.ts +++ b/test/datasource/hex.spec.ts @@ -27,7 +27,12 @@ describe('datasource/hex', () => { ).toBeNull(); }); it('returns null for missing fields', async () => { - got.mockReturnValueOnce({ res: {} }); + got.mockReturnValueOnce({}); + expect( + await getPkgReleases({ lookupName: 'non_existent_package' }) + ).toBeNull(); + + got.mockReturnValueOnce({ body: {} }); expect( await getPkgReleases({ lookupName: 'non_existent_package' }) ).toBeNull(); @@ -48,6 +53,26 @@ describe('datasource/hex', () => { ); expect(await getPkgReleases({ lookupName: 'some_package' })).toBeNull(); }); + it('throws for 429', async () => { + got.mockImplementationOnce(() => + Promise.reject({ + statusCode: 429, + }) + ); + await expect( + getPkgReleases({ lookupName: 'some_crate' }) + ).rejects.toThrowError('registry-failure'); + }); + it('throws for 5xx', async () => { + got.mockImplementationOnce(() => + Promise.reject({ + statusCode: 502, + }) + ); + await expect( + getPkgReleases({ lookupName: 'some_crate' }) + ).rejects.toThrowError('registry-failure'); + }); it('returns null for unknown error', async () => { got.mockImplementationOnce(() => { throw new Error(); diff --git a/test/manager/mix/__snapshots__/artifacts.spec.ts.snap b/test/manager/mix/__snapshots__/artifacts.spec.ts.snap new file mode 100644 index 0000000000..ea5a8db318 --- /dev/null +++ b/test/manager/mix/__snapshots__/artifacts.spec.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`.updateArtifacts() catches errors 1`] = ` +Array [ + Object { + "lockFileError": Object { + "lockFile": "mix.lock", + "stderr": "not found", + }, + }, +] +`; + +exports[`.updateArtifacts() returns updated mix.lock 1`] = ` +Array [ + Object { + "file": Object { + "contents": "New mix.lock", + "name": "mix.lock", + }, + }, +] +`; diff --git a/test/manager/mix/__snapshots__/extract.spec.ts.snap b/test/manager/mix/__snapshots__/extract.spec.ts.snap new file mode 100644 index 0000000000..ea0aecc3cf --- /dev/null +++ b/test/manager/mix/__snapshots__/extract.spec.ts.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lib/manager/mix/extract extractPackageFile() extracts all dependencies 1`] = ` +Array [ + Object { + "currentValue": "~> 0.8.1", + "datasource": "hex", + "depName": "postgrex", + "lookupName": "postgrex", + "managerData": Object { + "lineNumber": 18, + }, + }, + Object { + "currentValue": ">2.1.0 or <=3.0.0", + "datasource": "hex", + "depName": "ecto", + "lookupName": "ecto", + "managerData": Object { + "lineNumber": 19, + }, + }, + Object { + "currentValue": "ninenines/cowboy", + "datasource": "github", + "depName": "cowboy", + "managerData": Object { + "lineNumber": 20, + }, + "skipReason": "non-hex depTypes", + }, + Object { + "currentValue": "~> 1.0", + "datasource": "hex", + "depName": "secret", + "lookupName": "secret:acme", + "managerData": Object { + "lineNumber": 21, + }, + }, + Object { + "currentValue": ">2.1.0 and <=3.0.0", + "datasource": "hex", + "depName": "ex_doc", + "lookupName": "ex_doc", + "managerData": Object { + "lineNumber": 22, + }, + }, + Object { + "currentValue": ">= 1.0.0", + "datasource": "hex", + "depName": "jason", + "lookupName": "jason", + "managerData": Object { + "lineNumber": 24, + }, + }, + Object { + "currentValue": "~> 1.0", + "datasource": "hex", + "depName": "jason", + "lookupName": "jason", + "managerData": Object { + "lineNumber": 24, + }, + }, +] +`; + +exports[`lib/manager/mix/extract extractPackageFile() returns empty for invalid dependency file 1`] = ` +Object { + "deps": Array [], +} +`; diff --git a/test/manager/mix/_fixtures/mix.exs b/test/manager/mix/_fixtures/mix.exs new file mode 100644 index 0000000000..1d3194f3c8 --- /dev/null +++ b/test/manager/mix/_fixtures/mix.exs @@ -0,0 +1,29 @@ +defmodule MyProject.MixProject do + use Mix.Project + + def project() do + [ + app: :my_project, + version: "0.0.1", + elixir: "~> 1.0", + deps: deps(), + ] + end + + def application() do + [] + end + + defp deps() do + [ + {:postgrex, "~> 0.8.1"}, + {:ecto, ">2.1.0 or <=3.0.0"}, + {:cowboy, github: "ninenines/cowboy"}, + {:secret, "~> 1.0", organization: "acme"}, + {:ex_doc, ">2.1.0 and <=3.0.0"}, + {:jason, ">= 1.0.0"}, + {:jason, "~> 1.0", + optional: true}, + ] + end +end \ No newline at end of file diff --git a/test/manager/mix/artifacts.spec.ts b/test/manager/mix/artifacts.spec.ts new file mode 100644 index 0000000000..e62f1b4ce1 --- /dev/null +++ b/test/manager/mix/artifacts.spec.ts @@ -0,0 +1,71 @@ +import _fs from 'fs-extra'; +import { exec as _exec } from '../../../lib/util/exec'; +import { platform as _platform } from '../../../lib/platform'; +import { updateArtifacts } from '../../../lib/manager/mix'; + +const fs: any = _fs; +const exec: any = _exec; +const platform: any = _platform; + +jest.mock('fs-extra'); +jest.mock('../../../lib/util/exec'); +jest.mock('../../../lib/platform'); + +const config = { + localDir: '/tmp/github/some/repo', +}; + +describe('.updateArtifacts()', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns null if no mix.lock found', async () => { + expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull(); + }); + it('returns null if no updatedDeps were provided', async () => { + expect(await updateArtifacts('mix.exs', [], '', config)).toBeNull(); + }); + it('returns null if no local directory found', async () => { + const noLocalDirConfig = { + localDir: null, + }; + expect( + await updateArtifacts('mix.exs', ['plug'], '', noLocalDirConfig) + ).toBeNull(); + }); + it('returns null if updatedDeps is empty', async () => { + expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull(); + }); + it('returns null if unchanged', async () => { + platform.getFile.mockReturnValueOnce('Current mix.lock'); + exec.mockReturnValue({ + stdout: '', + stderr: '', + }); + fs.readFile.mockReturnValueOnce('Current mix.lock'); + expect(await updateArtifacts('mix.exs', ['plug'], '', config)).toBeNull(); + }); + it('returns updated mix.lock', async () => { + platform.getFile.mockReturnValueOnce('Old mix.lock'); + exec.mockReturnValue({ + stdout: '', + stderr: '', + }); + fs.readFile.mockImplementationOnce(() => 'New mix.lock'); + expect( + await updateArtifacts('mix.exs', ['plug'], '{}', { + ...config, + binarySource: 'docker', + }) + ).toMatchSnapshot(); + }); + it('catches errors', async () => { + platform.getFile.mockReturnValueOnce('Current mix.lock'); + fs.outputFile.mockImplementationOnce(() => { + throw new Error('not found'); + }); + expect( + await updateArtifacts('mix.exs', ['plug'], '{}', config) + ).toMatchSnapshot(); + }); +}); diff --git a/test/manager/mix/extract.spec.ts b/test/manager/mix/extract.spec.ts new file mode 100644 index 0000000000..ea4ccd93e0 --- /dev/null +++ b/test/manager/mix/extract.spec.ts @@ -0,0 +1,20 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { extractPackageFile } from '../../../lib/manager/mix'; + +const sample = fs.readFileSync( + path.resolve(__dirname, './_fixtures/mix.exs'), + 'utf-8' +); + +describe('lib/manager/mix/extract', () => { + describe('extractPackageFile()', () => { + it('returns empty for invalid dependency file', () => { + expect(extractPackageFile('nothing here')).toMatchSnapshot(); + }); + it('extracts all dependencies', () => { + const res = extractPackageFile(sample).deps; + expect(res).toMatchSnapshot(); + }); + }); +}); diff --git a/test/manager/mix/update.spec.ts b/test/manager/mix/update.spec.ts new file mode 100644 index 0000000000..3c7bdd48d8 --- /dev/null +++ b/test/manager/mix/update.spec.ts @@ -0,0 +1,50 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { updateDependency } from '../../../lib/manager/mix'; + +const sample = fs.readFileSync( + path.resolve(__dirname, './_fixtures/mix.exs'), + 'utf-8' +); + +describe('lib/manager/mix/update', () => { + describe('updateDependency', () => { + it('replaces existing value', () => { + const upgrade = { + depName: 'postgrex', + managerData: { lineNumber: 18 }, + newValue: '~> 0.8.2', + }; + const res = updateDependency(sample, upgrade); + expect(res).not.toEqual(sample); + expect(res.includes(upgrade.newValue)).toBe(true); + }); + it('return the same', () => { + const upgrade = { + depName: 'postgrex', + managerData: { lineNumber: 18 }, + newValue: '~> 0.8.1', + }; + const res = updateDependency(sample, upgrade); + expect(res).toEqual(sample); + }); + it('returns null if wrong line', () => { + const upgrade = { + depName: 'postgrex', + managerData: { lineNumber: 19 }, + newValue: '~> 0.8.2', + }; + const res = updateDependency(sample, upgrade); + expect(res).toBeNull(); + }); + it('returns null for unsupported depType', () => { + const upgrade = { + depName: 'cowboy', + managerData: { lineNumber: 19 }, + newValue: '~> 0.8.2', + }; + const res = updateDependency(sample, upgrade); + expect(res).toBeNull(); + }); + }); +}); diff --git a/test/versioning/hex.spec.ts b/test/versioning/hex.spec.ts index c86d93f4e3..6a218de89f 100644 --- a/test/versioning/hex.spec.ts +++ b/test/versioning/hex.spec.ts @@ -1,115 +1,120 @@ -import { api as semver } from '../../lib/versioning/hex'; +import { api as hexScheme } from '../../lib/versioning/hex'; describe('lib/versioning/hex', () => { - describe('semver.matches()', () => { + describe('hexScheme.matches()', () => { it('handles tilde greater than', () => { - expect(semver.matches('4.2.0', '~> 4.0')).toBe(true); - expect(semver.matches('2.1.0', '~> 2.0.0')).toBe(false); - expect(semver.matches('2.0.0', '>= 2.0.0 and < 2.1.0')).toBe(true); - expect(semver.matches('2.1.0', '== 2.0.0 or < 2.1.0')).toBe(false); + expect(hexScheme.matches('4.2.0', '~> 4.0')).toBe(true); + expect(hexScheme.matches('2.1.0', '~> 2.0.0')).toBe(false); + expect(hexScheme.matches('2.0.0', '>= 2.0.0 and < 2.1.0')).toBe(true); + expect(hexScheme.matches('2.1.0', '== 2.0.0 or < 2.1.0')).toBe(false); }); }); it('handles tilde greater than', () => { expect( - semver.maxSatisfyingVersion( + hexScheme.maxSatisfyingVersion( ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'], '~> 4.0' ) ).toBe('4.2.0'); expect( - semver.maxSatisfyingVersion( + hexScheme.maxSatisfyingVersion( ['0.4.0', '0.5.0', '4.0.0', '4.2.0', '5.0.0'], '~> 4.0.0' ) ).toBe('4.0.0'); }); - describe('semver.isValid()', () => { + describe('hexScheme.isValid()', () => { it('handles and', () => { - expect(semver.isValid('>= 1.0.0 and <= 2.0.0')).toBeTruthy(); + expect(hexScheme.isValid('>= 1.0.0 and <= 2.0.0')).toBeTruthy(); }); it('handles or', () => { - expect(semver.isValid('>= 1.0.0 or <= 2.0.0')).toBeTruthy(); + expect(hexScheme.isValid('>= 1.0.0 or <= 2.0.0')).toBeTruthy(); }); it('handles !=', () => { - expect(semver.isValid('!= 1.0.0')).toBeTruthy(); + expect(hexScheme.isValid('!= 1.0.0')).toBeTruthy(); }); }); - describe('semver.isLessThanRange()', () => { + describe('hexScheme.isLessThanRange()', () => { it('handles and', () => { - expect(semver.isLessThanRange('0.1.0', '>= 1.0.0 and <= 2.0.0')).toBe( + expect(hexScheme.isLessThanRange('0.1.0', '>= 1.0.0 and <= 2.0.0')).toBe( true ); - expect(semver.isLessThanRange('1.9.0', '>= 1.0.0 and <= 2.0.0')).toBe( + expect(hexScheme.isLessThanRange('1.9.0', '>= 1.0.0 and <= 2.0.0')).toBe( false ); }); it('handles or', () => { - expect(semver.isLessThanRange('0.9.0', '>= 1.0.0 or >= 2.0.0')).toBe( + expect(hexScheme.isLessThanRange('0.9.0', '>= 1.0.0 or >= 2.0.0')).toBe( true ); - expect(semver.isLessThanRange('1.9.0', '>= 1.0.0 or >= 2.0.0')).toBe( + expect(hexScheme.isLessThanRange('1.9.0', '>= 1.0.0 or >= 2.0.0')).toBe( false ); }); }); - describe('semver.minSatisfyingVersion()', () => { + describe('hexScheme.minSatisfyingVersion()', () => { it('handles tilde greater than', () => { expect( - semver.minSatisfyingVersion( + hexScheme.minSatisfyingVersion( ['0.4.0', '0.5.0', '4.2.0', '5.0.0'], '~> 4.0' ) ).toBe('4.2.0'); expect( - semver.minSatisfyingVersion( + hexScheme.minSatisfyingVersion( ['0.4.0', '0.5.0', '4.2.0', '5.0.0'], '~> 4.0.0' ) ).toBeNull(); }); }); - describe('semver.getNewValue()', () => { + describe('hexScheme.getNewValue()', () => { it('handles tilde greater than', () => { - expect(semver.getNewValue('~> 1.2', 'replace', '1.2.3', '2.0.7')).toEqual( - '~> 2.0' - ); - expect(semver.getNewValue('~> 1.2', 'pin', '1.2.3', '2.0.7')).toEqual( + expect( + hexScheme.getNewValue('~> 1.2', 'replace', '1.2.3', '2.0.7') + ).toEqual('~> 2.0'); + expect(hexScheme.getNewValue('~> 1.2', 'pin', '1.2.3', '2.0.7')).toEqual( '2.0.7' ); - expect(semver.getNewValue('~> 1.2', 'bump', '1.2.3', '2.0.7')).toEqual( + expect(hexScheme.getNewValue('~> 1.2', 'bump', '1.2.3', '2.0.7')).toEqual( '~> 2' ); expect( - semver.getNewValue('~> 1.2.0', 'replace', '1.2.3', '2.0.7') + hexScheme.getNewValue('~> 1.2.0', 'replace', '1.2.3', '2.0.7') ).toEqual('~> 2.0.0'); - expect(semver.getNewValue('~> 1.2.0', 'pin', '1.2.3', '2.0.7')).toEqual( - '2.0.7' - ); - expect(semver.getNewValue('~> 1.2.0', 'bump', '1.2.3', '2.0.7')).toEqual( - '~> 2.0.7' - ); + expect( + hexScheme.getNewValue('~> 1.2.0', 'pin', '1.2.3', '2.0.7') + ).toEqual('2.0.7'); + expect( + hexScheme.getNewValue('~> 1.2.0', 'bump', '1.2.3', '2.0.7') + ).toEqual('~> 2.0.7'); }); }); it('handles and', () => { expect( - semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'widen', '1.2.3', '2.0.7') + hexScheme.getNewValue('>= 1.0.0 and <= 2.0.0', 'widen', '1.2.3', '2.0.7') ).toEqual('>= 1.0.0 and <= 2.0.7'); expect( - semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'replace', '1.2.3', '2.0.7') + hexScheme.getNewValue( + '>= 1.0.0 and <= 2.0.0', + 'replace', + '1.2.3', + '2.0.7' + ) ).toEqual('<= 2.0.7'); expect( - semver.getNewValue('>= 1.0.0 and <= 2.0.0', 'pin', '1.2.3', '2.0.7') + hexScheme.getNewValue('>= 1.0.0 and <= 2.0.0', 'pin', '1.2.3', '2.0.7') ).toEqual('2.0.7'); }); it('handles or', () => { expect( - semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'widen', '1.2.3', '2.0.7') + hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'widen', '1.2.3', '2.0.7') ).toEqual('>= 1.0.0 or <= 2.0.7'); expect( - semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'replace', '1.2.3', '2.0.7') + hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'replace', '1.2.3', '2.0.7') ).toEqual('<= 2.0.7'); expect( - semver.getNewValue('>= 1.0.0 or <= 2.0.0', 'pin', '1.2.3', '2.0.7') + hexScheme.getNewValue('>= 1.0.0 or <= 2.0.0', 'pin', '1.2.3', '2.0.7') ).toEqual('2.0.7'); }); }); diff --git a/test/workers/repository/extract/__snapshots__/index.spec.js.snap b/test/workers/repository/extract/__snapshots__/index.spec.js.snap index e5b3d0b453..ece6795070 100644 --- a/test/workers/repository/extract/__snapshots__/index.spec.js.snap +++ b/test/workers/repository/extract/__snapshots__/index.spec.js.snap @@ -68,6 +68,9 @@ Object { "meteor": Array [ Object {}, ], + "mix": Array [ + Object {}, + ], "npm": Array [ Object {}, ], -- GitLab