feat(gradle): gradle versioning scheme (#5789)

This commit is contained in:
Sergio Zharinov 2020-03-29 20:22:08 +04:00 committed by GitHub
parent cb56b54351
commit bb6ab0bed3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 784 additions and 3 deletions

View file

@ -4,7 +4,7 @@ import { Stats } from 'fs';
import upath from 'upath'; import upath from 'upath';
import { exec, ExecOptions } from '../../util/exec'; import { exec, ExecOptions } from '../../util/exec';
import { logger } from '../../logger'; import { logger } from '../../logger';
import * as mavenVersioning from '../../versioning/maven'; import * as gradleVersioning from '../../versioning/gradle';
import { import {
ExtractConfig, ExtractConfig,
PackageFile, PackageFile,
@ -188,5 +188,5 @@ export const language = LANGUAGE_JAVA;
export const defaultConfig = { export const defaultConfig = {
fileMatch: ['\\.gradle(\\.kts)?$', '(^|/)gradle.properties$'], fileMatch: ['\\.gradle(\\.kts)?$', '(^|/)gradle.properties$'],
timeout: 600, timeout: 600,
versioning: mavenVersioning.id, versioning: gradleVersioning.id,
}; };

View file

@ -0,0 +1,301 @@
export enum TokenType {
Number = 1,
String,
}
type Token = {
type: TokenType;
val: string | number;
};
function iterateChars(str: string, cb: (p: string, n: string) => void): void {
let prev = null;
let next = null;
for (let i = 0; i < str.length; i += 1) {
next = str.charAt(i);
cb(prev, next);
prev = next;
}
cb(prev, null);
}
function isSeparator(char: string): boolean {
return /^[-._+]$/i.test(char);
}
function isDigit(char: string): boolean {
return /^\d$/.test(char);
}
function isLetter(char: string): boolean {
return !isSeparator(char) && !isDigit(char);
}
function isTransition(prevChar: string, nextChar: string): boolean {
return (
(isDigit(prevChar) && isLetter(nextChar)) ||
(isLetter(prevChar) && isDigit(nextChar))
);
}
export function tokenize(versionStr: string): Token[] | null {
let result = [];
let currentVal = '';
function yieldToken(): void {
if (currentVal === '') {
result = null;
}
if (result) {
const val = currentVal;
if (/^\d+$/.test(val)) {
result.push({
type: TokenType.Number,
val: parseInt(val, 10),
});
} else {
result.push({
type: TokenType.String,
val,
});
}
}
}
iterateChars(versionStr, (prevChar, nextChar) => {
if (nextChar === null) {
yieldToken();
} else if (isSeparator(nextChar)) {
yieldToken();
currentVal = '';
} else if (prevChar !== null && isTransition(prevChar, nextChar)) {
yieldToken();
currentVal = nextChar;
} else {
currentVal = currentVal.concat(nextChar);
}
});
return result;
}
function isSpecial(str: string, special: string): boolean {
return str.toLowerCase() === special;
}
function checkSpecial(left: string, right: string, tag: string): number | null {
if (isSpecial(left, tag) && isSpecial(right, tag)) {
return 0;
}
if (isSpecial(left, tag)) {
return 1;
}
if (isSpecial(right, tag)) {
return -1;
}
return null;
}
function stringTokenCmp(left: string, right: string): number {
const dev = checkSpecial(left, right, 'dev');
if (dev !== null) {
return dev ? -dev : 0;
}
const final = checkSpecial(left, right, 'final');
if (final !== null) {
return final;
}
const release = checkSpecial(left, right, 'release');
if (release !== null) {
return release;
}
const rc = checkSpecial(left, right, 'rc');
if (rc !== null) {
return rc;
}
if (left === 'SNAPSHOT' || right === 'SNAPSHOT') {
if (left.toLowerCase() < right.toLowerCase()) {
return -1;
}
if (left.toLowerCase() > right.toLowerCase()) {
return 1;
}
} else {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
}
return 0;
}
function tokenCmp(left: Token | null, right: Token | null): number {
if (left === null) {
if (right.type === TokenType.String) {
return 1;
}
return -1;
}
if (right === null) {
if (left.type === TokenType.String) {
return -1;
}
return 1;
}
if (left.type === TokenType.Number && right.type === TokenType.Number) {
if (left.val < right.val) {
return -1;
}
if (left.val > right.val) {
return 1;
}
} else if (typeof left.val === 'string' && typeof right.val === 'string') {
return stringTokenCmp(left.val, right.val);
} else if (right.type === TokenType.Number) {
return -1;
} else if (left.type === TokenType.Number) {
return 1;
}
return 0;
}
export function compare(left: string, right: string): number {
const leftTokens = tokenize(left);
const rightTokens = tokenize(right);
const length = Math.max(leftTokens.length, rightTokens.length);
for (let idx = 0; idx < length; idx += 1) {
const leftToken = leftTokens[idx] || null;
const rightToken = rightTokens[idx] || null;
const cmpResult = tokenCmp(leftToken, rightToken);
if (cmpResult !== 0) {
return cmpResult;
}
}
return 0;
}
export function isVersion(input: string): boolean {
if (!input) {
return false;
}
if (!/^[-._+a-zA-Z0-9]+$/i.test(input)) {
return false;
}
if (/^latest\.?/i.test(input)) {
return false;
}
const tokens = tokenize(input);
return !!tokens && !!tokens.length;
}
interface PrefixRange {
tokens: Token[];
}
export enum RangeBound {
Inclusive = 1,
Exclusive,
}
interface MavenBasedRange {
leftBound: RangeBound;
leftBoundStr: string;
leftVal: string | null;
separator: string;
rightBound: RangeBound;
rightBoundStr: string;
rightVal: string | null;
}
export function parsePrefixRange(input: string): PrefixRange | null {
if (!input) {
return null;
}
if (input.trim() === '+') {
return { tokens: [] };
}
const postfixRegex = /[-._]\+$/;
if (postfixRegex.test(input)) {
const prefixValue = input.replace(/[-._]\+$/, '');
const tokens = tokenize(prefixValue);
return tokens ? { tokens } : null;
}
return null;
}
const mavenBasedRangeRegex = /^(?<leftBoundStr>[[\](]\s*)(?<leftVal>[-._+a-zA-Z0-9]*?)(?<separator>\s*,\s*)(?<rightVal>[-._+a-zA-Z0-9]*?)(?<rightBoundStr>\s*[[\])])$/;
export function parseMavenBasedRange(input: string): MavenBasedRange | null {
if (!input) {
return null;
}
const match = mavenBasedRangeRegex.exec(input);
if (match) {
const { leftBoundStr, separator, rightBoundStr } = match.groups;
let { leftVal, rightVal } = match.groups;
if (!leftVal) {
leftVal = null;
}
if (!rightVal) {
rightVal = null;
}
const isVersionLeft = isVersion(leftVal);
const isVersionRight = isVersion(rightVal);
if (
(leftVal === null || isVersionLeft) &&
(rightVal === null || isVersionRight)
) {
if (isVersionLeft && isVersionRight && compare(leftVal, rightVal) === 1) {
return null;
}
const leftBound =
leftBoundStr.trim() === '['
? RangeBound.Inclusive
: RangeBound.Exclusive;
const rightBound =
rightBoundStr.trim() === ']'
? RangeBound.Inclusive
: RangeBound.Exclusive;
return {
leftBound,
leftBoundStr,
leftVal,
separator,
rightBound,
rightBoundStr,
rightVal,
};
}
}
return null;
}
export function isValid(str: string): boolean {
if (!str) {
return false;
}
return (
isVersion(str) || !!parsePrefixRange(str) || !!parseMavenBasedRange(str)
);
}

View file

@ -0,0 +1,270 @@
import { api } from '.';
import { compare, parsePrefixRange, parseMavenBasedRange } from './compare';
describe('versioning/gradle/compare', () => {
it('returns equality', () => {
expect(compare('1', '1')).toEqual(0);
expect(compare('a', 'a')).toEqual(0);
expect(compare('1a1', '1.a.1')).toEqual(0);
expect(compare('1a1', '1-a-1')).toEqual(0);
expect(compare('1a1', '1_a_1')).toEqual(0);
expect(compare('1a1', '1+a+1')).toEqual(0);
expect(compare('1.a.1', '1a1')).toEqual(0);
expect(compare('1-a-1', '1a1')).toEqual(0);
expect(compare('1_a_1', '1a1')).toEqual(0);
expect(compare('1+a+1', '1a1')).toEqual(0);
expect(compare('1.a.1', '1-a+1')).toEqual(0);
expect(compare('1-a+1', '1.a-1')).toEqual(0);
expect(compare('1.a-1', '1a1')).toEqual(0);
expect(compare('dev', 'dev')).toEqual(0);
expect(compare('rc', 'rc')).toEqual(0);
expect(compare('release', 'release')).toEqual(0);
expect(compare('final', 'final')).toEqual(0);
expect(compare('snapshot', 'SNAPSHOT')).toEqual(0);
expect(compare('SNAPSHOT', 'snapshot')).toEqual(0);
});
it('returns less than', () => {
expect(compare('1.1', '1.2')).toEqual(-1);
expect(compare('1.a', '1.1')).toEqual(-1);
expect(compare('1.A', '1.B')).toEqual(-1);
expect(compare('1.B', '1.a')).toEqual(-1);
expect(compare('1.a', '1.b')).toEqual(-1);
expect(compare('1.1', '1.1.0')).toEqual(-1);
expect(compare('1.1.a', '1.1')).toEqual(-1);
expect(compare('1.0-dev', '1.0-alpha')).toEqual(-1);
expect(compare('1.0-alpha', '1.0-rc')).toEqual(-1);
expect(compare('1.0-zeta', '1.0-rc')).toEqual(-1);
expect(compare('1.0-rc', '1.0-release')).toEqual(-1);
expect(compare('1.0-release', '1.0-final')).toEqual(-1);
expect(compare('1.0-final', '1.0')).toEqual(-1);
expect(compare('1.0-alpha', '1.0-SNAPSHOT')).toEqual(-1);
expect(compare('1.0-SNAPSHOT', '1.0-zeta')).toEqual(-1);
expect(compare('1.0-zeta', '1.0-rc')).toEqual(-1);
expect(compare('1.0-rc', '1.0')).toEqual(-1);
expect(compare('1.0', '1.0-20150201.121010-123')).toEqual(-1);
expect(compare('1.0-20150201.121010-123', '1.1')).toEqual(-1);
expect(compare('sNaPsHoT', 'snapshot')).toEqual(-1);
});
it('returns greater than', () => {
expect(compare('1.2', '1.1')).toEqual(1);
expect(compare('1.1', '1.1.a')).toEqual(1);
expect(compare('1.B', '1.A')).toEqual(1);
expect(compare('1.a', '1.B')).toEqual(1);
expect(compare('1.b', '1.a')).toEqual(1);
expect(compare('1.1.0', '1.1')).toEqual(1);
expect(compare('1.1', '1.a')).toEqual(1);
expect(compare('1.0-alpha', '1.0-dev')).toEqual(1);
expect(compare('1.0-rc', '1.0-alpha')).toEqual(1);
expect(compare('1.0-rc', '1.0-zeta')).toEqual(1);
expect(compare('1.0-release', '1.0-rc')).toEqual(1);
expect(compare('1.0-final', '1.0-release')).toEqual(1);
expect(compare('1.0', '1.0-final')).toEqual(1);
expect(compare('1.0-SNAPSHOT', '1.0-alpha')).toEqual(1);
expect(compare('1.0-zeta', '1.0-SNAPSHOT')).toEqual(1);
expect(compare('1.0-rc', '1.0-zeta')).toEqual(1);
expect(compare('1.0', '1.0-rc')).toEqual(1);
expect(compare('1.0-20150201.121010-123', '1.0')).toEqual(1);
expect(compare('1.1', '1.0-20150201.121010-123')).toEqual(1);
expect(compare('snapshot', 'sNaPsHoT')).toEqual(1);
});
const invalidPrefixRanges = [
'',
'1.2.3-SNAPSHOT', // versions should be handled separately
'1.2..+',
'1.2.++',
];
it('filters out incorrect prefix ranges', () => {
invalidPrefixRanges.forEach(rangeStr => {
const range = parsePrefixRange(rangeStr);
expect(range).toBeNull();
});
});
const invalidMavenBasedRanges = [
'',
'1.2.3-SNAPSHOT', // versions should be handled separately
'[]',
'(',
'[',
',',
'[1.0',
'1.0]',
'[1.0],',
',[1.0]',
'[2.0,1.0)',
'[1.2,1.3],1.4',
'[1.2,,1.3]',
'[1,[2,3],4]',
'[1.3,1.2]',
];
it('filters out incorrect maven-based ranges', () => {
invalidMavenBasedRanges.forEach(rangeStr => {
const range = parseMavenBasedRange(rangeStr);
expect(range).toBeNull();
});
});
});
describe('versioning/gradle', () => {
it('isValid', () => {
expect(api.isValid('1.0.0')).toBe(true);
expect(api.isValid('[1.12.6,1.18.6]')).toBe(true);
expect(api.isValid(undefined)).toBe(false);
});
it('isVersion', () => {
expect(api.isVersion('')).toBe(false);
expect(api.isVersion('latest.integration')).toBe(false);
expect(api.isVersion('latest.release')).toBe(false);
expect(api.isVersion('latest')).toBe(false);
expect(api.isVersion('1')).toBe(true);
expect(api.isVersion('a')).toBe(true);
expect(api.isVersion('A')).toBe(true);
expect(api.isVersion('1a1')).toBe(true);
expect(api.isVersion('1.a.1')).toBe(true);
expect(api.isVersion('1-a-1')).toBe(true);
expect(api.isVersion('1_a_1')).toBe(true);
expect(api.isVersion('1+a+1')).toBe(true);
expect(api.isVersion('1!a!1')).toBe(false);
expect(api.isVersion('1.0-20150201.121010-123')).toBe(true);
expect(api.isVersion('dev')).toBe(true);
expect(api.isVersion('rc')).toBe(true);
expect(api.isVersion('release')).toBe(true);
expect(api.isVersion('final')).toBe(true);
expect(api.isVersion('SNAPSHOT')).toBe(true);
expect(api.isVersion('1.2')).toBe(true);
expect(api.isVersion('1..2')).toBe(false);
expect(api.isVersion('1++2')).toBe(false);
expect(api.isVersion('1--2')).toBe(false);
expect(api.isVersion('1__2')).toBe(false);
});
it('checks if version is stable', () => {
expect(api.isStable('')).toBeNull();
expect(api.isStable('foobar')).toBe(true);
expect(api.isStable('final')).toBe(true);
expect(api.isStable('1')).toBe(true);
expect(api.isStable('1.2')).toBe(true);
expect(api.isStable('1.2.3')).toBe(true);
expect(api.isStable('1.2.3.4')).toBe(true);
expect(api.isStable('v1.2.3.4')).toBe(true);
expect(api.isStable('1-alpha-1')).toBe(false);
expect(api.isStable('1-b1')).toBe(false);
expect(api.isStable('1-foo')).toBe(true);
expect(api.isStable('1-final-1.0.0')).toBe(true);
expect(api.isStable('1-release')).toBe(true);
expect(api.isStable('1.final')).toBe(true);
expect(api.isStable('1.0milestone1')).toBe(false);
expect(api.isStable('1-sp')).toBe(true);
expect(api.isStable('1-ga-1')).toBe(true);
expect(api.isStable('1.3-groovy-2.5')).toBe(true);
expect(api.isStable('1.3-RC1-groovy-2.5')).toBe(false);
expect(api.isStable('Hoxton.RELEASE')).toBe(true);
expect(api.isStable('Hoxton.SR')).toBe(true);
expect(api.isStable('Hoxton.SR1')).toBe(true);
// https://github.com/renovatebot/renovate/pull/5789
expect(api.isStable('1.3.5-native-mt-1.3.71-release-429')).toBe(false);
});
it('returns major version', () => {
expect(api.getMajor('')).toBeNull();
expect(api.getMajor('1')).toEqual(1);
expect(api.getMajor('1.2')).toEqual(1);
expect(api.getMajor('1.2.3')).toEqual(1);
expect(api.getMajor('v1.2.3')).toEqual(1);
expect(api.getMajor('1rc42')).toEqual(1);
});
it('returns minor version', () => {
expect(api.getMinor('')).toBeNull();
expect(api.getMinor('1')).toEqual(0);
expect(api.getMinor('1.2')).toEqual(2);
expect(api.getMinor('1.2.3')).toEqual(2);
expect(api.getMinor('v1.2.3')).toEqual(2);
expect(api.getMinor('1.2.3.4')).toEqual(2);
expect(api.getMinor('1-rc42')).toEqual(0);
});
it('returns patch version', () => {
expect(api.getPatch('')).toBeNull();
expect(api.getPatch('1')).toEqual(0);
expect(api.getPatch('1.2')).toEqual(0);
expect(api.getPatch('1.2.3')).toEqual(3);
expect(api.getPatch('v1.2.3')).toEqual(3);
expect(api.getPatch('1.2.3.4')).toEqual(3);
expect(api.getPatch('1-rc10')).toEqual(0);
expect(api.getPatch('1-rc42-1')).toEqual(0);
});
it('matches against maven ranges', () => {
expect(api.matches('0', '[0,1]')).toBe(true);
expect(api.matches('1', '[0,1]')).toBe(true);
expect(api.matches('0', '(0,1)')).toBe(false);
expect(api.matches('1', '(0,1)')).toBe(false);
expect(api.matches('1', '(0,2)')).toBe(true);
expect(api.matches('1', '[0,2]')).toBe(true);
expect(api.matches('1', '(,1]')).toBe(true);
expect(api.matches('1', '(,1)')).toBe(false);
expect(api.matches('1', '[1,)')).toBe(true);
expect(api.matches('1', '(1,)')).toBe(false);
expect(api.matches('1', '[[]]')).toBe(null);
expect(api.matches('0', '')).toBe(false);
expect(api.matches('1', '1')).toBe(true);
expect(api.matches('1.2.3', '1.2.+')).toBe(true);
expect(api.matches('1.2.3.4', '1.2.+')).toBe(true);
expect(api.matches('1.3.0', '1.2.+')).toBe(false);
expect(api.matches('foo', '+')).toBe(true);
expect(api.matches('1', '+')).toBe(true);
expect(api.matches('99999999999', '+')).toBe(true);
});
it('api', () => {
expect(api.isGreaterThan('1.1', '1')).toBe(true);
expect(api.minSatisfyingVersion(['0', '1.5', '1', '2'], '1.+')).toBe('1');
expect(api.maxSatisfyingVersion(['0', '1', '1.5', '2'], '1.+')).toBe('1.5');
expect(
api.getNewValue({
currentValue: '1',
rangeStrategy: null,
fromVersion: null,
toVersion: '1.1',
})
).toBe('1.1');
expect(
api.getNewValue({
currentValue: '[1.2.3,]',
rangeStrategy: null,
fromVersion: null,
toVersion: '1.2.4',
})
).toBe(null);
});
it('pins maven ranges', () => {
const sample = [
['[1.2.3]', '1.2.3', '1.2.4'],
['[1.0.0,1.2.3]', '1.0.0', '1.2.4'],
['[1.0.0,1.2.23]', '1.0.0', '1.2.23'],
['(,1.0]', '0.0.1', '2.0'],
['],1.0]', '0.0.1', '2.0'],
['(,1.0)', '0.1', '2.0'],
['],1.0[', '2.0', '],2.0['],
['[1.0,1.2],[1.3,1.5)', '1.0', '1.2.4'],
['[1.0,1.2],[1.3,1.5[', '1.0', '1.2.4'],
['[1.2.3,)', '1.2.3', '1.2.4'],
['[1.2.3,[', '1.2.3', '1.2.4'],
];
sample.forEach(([currentValue, fromVersion, toVersion]) => {
expect(
api.getNewValue({
currentValue,
rangeStrategy: 'pin',
fromVersion,
toVersion,
})
).toEqual(toVersion);
});
});
});

View file

@ -0,0 +1,210 @@
import { NewValueConfig, VersioningApi } from '../common';
import {
compare,
isValid,
isVersion,
parseMavenBasedRange,
parsePrefixRange,
RangeBound,
tokenize,
TokenType,
} from './compare';
export const id = 'gradle';
export const displayName = 'Gradle';
export const urls = [
'https://docs.gradle.org/current/userguide/single_versions.html#version_ordering',
];
export const supportsRanges = true;
export const supportedRangeStrategies = ['pin'];
const equals = (a: string, b: string): boolean => compare(a, b) === 0;
const getMajor = (version: string): number | null => {
if (isVersion(version)) {
const tokens = tokenize(version.replace(/^v/i, ''));
const majorToken = tokens[0];
if (majorToken && majorToken.type === TokenType.Number) {
return +majorToken.val;
}
}
return null;
};
const getMinor = (version: string): number | null => {
if (isVersion(version)) {
const tokens = tokenize(version.replace(/^v/i, ''));
const majorToken = tokens[0];
const minorToken = tokens[1];
if (
majorToken &&
majorToken.type === TokenType.Number &&
minorToken &&
minorToken.type === TokenType.Number
) {
return +minorToken.val;
}
return 0;
}
return null;
};
const getPatch = (version: string): number | null => {
if (isVersion(version)) {
const tokens = tokenize(version.replace(/^v/i, ''));
const majorToken = tokens[0];
const minorToken = tokens[1];
const patchToken = tokens[2];
if (
majorToken &&
majorToken.type === TokenType.Number &&
minorToken &&
minorToken.type === TokenType.Number &&
patchToken &&
patchToken.type === TokenType.Number
) {
return +patchToken.val;
}
return 0;
}
return null;
};
const isGreaterThan = (a: string, b: string): boolean => compare(a, b) === 1;
const unstable = new Set([
'a',
'alpha',
'b',
'beta',
'm',
'mt',
'milestone',
'rc',
'cr',
'snapshot',
]);
const isStable = (version: string): boolean | null => {
if (isVersion(version)) {
const tokens = tokenize(version);
for (const token of tokens) {
if (token.type === TokenType.String) {
const val = token.val.toString().toLowerCase();
if (unstable.has(val)) {
return false;
}
}
}
return true;
}
return null;
};
const matches = (a: string, b: string): boolean => {
if (!a || !isVersion(a) || !b) {
return false;
}
if (isVersion(b)) {
return equals(a, b);
}
const prefixRange = parsePrefixRange(b);
if (prefixRange) {
const tokens = prefixRange.tokens;
if (tokens.length === 0) {
return true;
}
const versionTokens = tokenize(a);
const x = versionTokens
.slice(0, tokens.length)
.map(({ val }) => val)
.join('.');
const y = tokens.map(({ val }) => val).join('.');
return equals(x, y);
}
const mavenBasedRange = parseMavenBasedRange(b);
if (!mavenBasedRange) {
return null;
}
const { leftBound, leftVal, rightBound, rightVal } = mavenBasedRange;
let leftResult = true;
let rightResult = true;
if (leftVal) {
leftResult =
leftBound === RangeBound.Exclusive
? compare(leftVal, a) === -1
: compare(leftVal, a) !== 1;
}
if (rightVal) {
rightResult =
rightBound === RangeBound.Exclusive
? compare(a, rightVal) === -1
: compare(a, rightVal) !== 1;
}
return leftResult && rightResult;
};
const maxSatisfyingVersion = (versions: string[], range: string): string => {
return versions.reduce((result, version) => {
if (matches(version, range)) {
if (!result) {
return version;
}
if (isGreaterThan(version, result)) {
return version;
}
}
return result;
}, null);
};
const minSatisfyingVersion = (versions: string[], range: string): string => {
return versions.reduce((result, version) => {
if (matches(version, range)) {
if (!result) {
return version;
}
if (compare(version, result) === -1) {
return version;
}
}
return result;
}, null);
};
function getNewValue({
currentValue,
rangeStrategy,
toVersion,
}: NewValueConfig): string | null {
if (isVersion(currentValue) || rangeStrategy === 'pin') {
return toVersion;
}
return null;
}
export const api: VersioningApi = {
equals,
getMajor,
getMinor,
getPatch,
isCompatible: isVersion,
isGreaterThan,
isSingleVersion: isVersion,
isStable,
isValid,
isVersion,
matches,
maxSatisfyingVersion,
minSatisfyingVersion,
getNewValue,
sortVersions: compare,
};
export default api;

View file

@ -129,7 +129,7 @@ describe('versioning/maven/compare', () => {
'[,1.0]', '[,1.0]',
]; ];
it('filters out incorrect ranges', () => { it('filters out incorrect ranges', () => {
Object.keys(invalidRanges).forEach(rangeStr => { invalidRanges.forEach(rangeStr => {
const range = parseRange(rangeStr); const range = parseRange(rangeStr);
expect(range).toBeNull(); expect(range).toBeNull();
expect(rangeToStr(range)).toBeNull(); expect(rangeToStr(range)).toBeNull();