Skip to content

Commit

Permalink
[FEATURE] Resolvers: Allow ranges / npm tags for version resolution
Browse files Browse the repository at this point in the history
The framework version resolution now supports all valid node semver
ranges and uses tags from the corresponding registry for version
resolution (not supported for Sapui5MavenSnapshotResolver).
  • Loading branch information
matz3 committed Sep 5, 2023
1 parent 5cde95a commit 2841004
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 52 deletions.
65 changes: 42 additions & 23 deletions lib/ui5Framework/AbstractResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,37 +215,56 @@ class AbstractResolver {
}

static async resolveVersion(version, {ui5HomeDir, cwd} = {}) {
if (version === "latest") {
const tagVersion = this.fetchTag("latest", {ui5HomeDir, cwd});
if (tagVersion) {
return tagVersion;
}
// Don't allow nullish values
// An empty string is a valid semver range that converts to "*", which should not be supported
if (!version) {
throw new Error(`Framework version specifier "${version}" is incorrect or not supported`);
}

let spec;
const isSnapshotVersion = version.toLowerCase().endsWith("-snapshot");
if (version === "latest" || version === "latest-snapshot") {
// Use a wildcard to resolve to the latest available version
spec = "*";
} else if (SEMVER_VERSION_REGEXP.test(version)) {
// Fully qualified version, can be used directly
spec = version;
} else {
if (isSnapshotVersion) {
const versionMatch = version.match(VERSION_RANGE_REGEXP);
if (versionMatch) {
if (isSnapshotVersion) {
// For snapshot version ranges we need to insert a stand-in "x" for the patch level
// and - in case none is provided - another "x" for the major version in order to
// make the semver check work: "1-SNAPSHOT" or "1.112-SNAPSHOT" becomes "1.112.x-SNAPSHOT"
spec = `${versionMatch[1]}.${versionMatch[2] || "x"}.x-SNAPSHOT`;
} else {
spec = version;
// For snapshot version ranges we need to insert a stand-in "x" for the patch level
// and - in case none is provided - another "x" for the major version in order to
// convert it to a valid semver range:
// "1-SNAPSHOT" becomes "1.x.x-SNAPSHOT" and "1.112-SNAPSHOT" becomes "1.112.x-SNAPSHOT"
version = `${versionMatch[1]}.${versionMatch[2] || "x"}.x-SNAPSHOT`;
}
}

let spec;

if (semver.validRange(version)) {
// Note: Fully qualified versions are also valid ranges
spec = version;
} else if (encodeURIComponent(version) === version) {
// Valid tag name (same check as npm does)
const allTags = await this.fetchAllTags({ui5HomeDir, cwd});
if (!allTags) {
// Resolver doesn't support tags (e.g. Sapui5MavenSnapshotResolver)
if (version === "latest" || version === "latest-snapshot") {
// Only latest and latest-snapshot are supported which both resolve
// to the latest available version.
// See isSnapshotVersion flag for -snapshot handling
spec = "*";
}
} else if (allTags[version]) {
// Use version from tag
spec = allTags[version];
} else {
throw new Error(`Framework version specifier "${version}" is incorrect or not supported`);
throw new Error(
`Could not resolve framework version via tag '${version}'. ` +
`Make sure the tag is available in the configured registry.`
);
}
}

// For all invalid cases which are not explicitly handled above
if (!spec) {
throw new Error(`Framework version specifier "${version}" is incorrect or not supported`);
}

const versions = await this.fetchAllVersions({ui5HomeDir, cwd});
const resolvedVersion = semver.maxSatisfying(versions, spec, {
includePrerelease: isSnapshotVersion
Expand Down Expand Up @@ -279,8 +298,8 @@ class AbstractResolver {
static fetchAllVersions(options) {
throw new Error("AbstractResolver: static fetchAllVersions must be implemented!");
}
static fetchTag(tagName, options) {
throw new Error("AbstractResolver: static fetchDistTag must be implemented!");
static fetchAllTags(options) {
return null;
}
}

Expand Down
5 changes: 2 additions & 3 deletions lib/ui5Framework/Openui5Resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,14 @@ class Openui5Resolver extends AbstractResolver {
return await installer.fetchPackageVersions({pkgName: OPENUI5_CORE_PACKAGE});
}

static async fetchTag(tagName, {ui5HomeDir, cwd} = {}) {
static async fetchAllTags({ui5HomeDir, cwd} = {}) {
const installer = new Installer({
cwd: cwd ? path.resolve(cwd) : process.cwd(),
ui5HomeDir:
ui5HomeDir ? path.resolve(ui5HomeDir) :
path.join(os.homedir(), ".ui5")
});
const distTags = await installer.fetchPackageDistTags({pkgName: OPENUI5_CORE_PACKAGE});
return distTags[tagName];
return installer.fetchPackageDistTags({pkgName: OPENUI5_CORE_PACKAGE});
}
}

Expand Down
5 changes: 2 additions & 3 deletions lib/ui5Framework/Sapui5Resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,15 +115,14 @@ class Sapui5Resolver extends AbstractResolver {
return await installer.fetchPackageVersions({pkgName: DIST_PKG_NAME});
}

static async fetchTag(tagName, {ui5HomeDir, cwd} = {}) {
static async fetchAllTags({ui5HomeDir, cwd} = {}) {
const installer = new Installer({
cwd: cwd ? path.resolve(cwd) : process.cwd(),
ui5HomeDir:
ui5HomeDir ? path.resolve(ui5HomeDir) :
path.join(os.homedir(), ".ui5")
});
const distTags = await installer.fetchPackageDistTags({pkgName: DIST_PKG_NAME});
return distTags[tagName];
return installer.fetchPackageDistTags({pkgName: DIST_PKG_NAME});
}
}

Expand Down
227 changes: 204 additions & 23 deletions test/lib/ui5framework/AbstractResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -815,60 +815,241 @@ test.serial("AbstractResolver: Static resolveVersion throws error for 'lts'", as
t.is(fetchAllVersionsStub.callCount, 0, "fetchAllVersions should not be called");
});

test.serial("AbstractResolver: Static resolveVersion throws error for '1.x'", async (t) => {
test.serial("AbstractResolver: Static resolveVersion resolves '1.x'", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions");
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0", "2.0.0"]);

const error = await t.throwsAsync(MyResolver.resolveVersion("1.x", {
const version = await MyResolver.resolveVersion("1.x", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));
});

t.is(error.message, `Framework version specifier "1.x" is incorrect or not supported`);
t.is(version, "1.76.0", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 0, "fetchAllVersions should not be called");
t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion throws error for '1.75.x'", async (t) => {
test.serial("AbstractResolver: Static resolveVersion resolves '1.75.x'", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions");
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0", "2.0.0"]);

const error = await t.throwsAsync(MyResolver.resolveVersion("1.75.x", {
const version = await MyResolver.resolveVersion("1.75.x", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));
});

t.is(error.message, `Framework version specifier "1.75.x" is incorrect or not supported`);
t.is(version, "1.75.1", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 0, "fetchAllVersions should not be called");
t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion throws error for '^1.75.0'", async (t) => {
test.serial("AbstractResolver: Static resolveVersion resolves '^1.75.0'", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions");
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0", "2.0.0"]);

const error = await t.throwsAsync(MyResolver.resolveVersion("^1.75.0", {
const version = await MyResolver.resolveVersion("^1.75.0", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));
});

t.is(error.message, `Framework version specifier "^1.75.0" is incorrect or not supported`);
t.is(version, "1.76.0", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 0, "fetchAllVersions should not be called");
t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion throws error for '~1.75.0'", async (t) => {
test.serial("AbstractResolver: Static resolveVersion resolves '~1.75.0'", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions");
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0", "2.0.0"]);

const version = await MyResolver.resolveVersion("~1.75.0", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});

t.is(version, "1.75.1", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion resolves '> 1.75.0 < 1.75.3'", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.75.2", "1.75.3"]);

const version = await MyResolver.resolveVersion("> 1.75.0 < 1.75.3", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});

t.is(version, "1.75.2", "Resolved version should be correct");

const error = await t.throwsAsync(MyResolver.resolveVersion("~1.75.0", {
t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion resolves 'next' using tags", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.0.0", "2.0.0"]);
const fetchAllTagsStub = sinon.stub(MyResolver, "fetchAllTags")
.resolves({
"latest": "1.0.0",
"next": "2.0.0"
});

const version = await MyResolver.resolveVersion("next", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});

t.is(version, "2.0.0", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
t.is(fetchAllTagsStub.callCount, 1, "fetchAllTagsStub should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllTags should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion resolves 'next' to a pre-release using tags", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.0.0", "2.0.0-SNAPSHOT"]);
const fetchAllTagsStub = sinon.stub(MyResolver, "fetchAllTags")
.resolves({
"latest": "1.0.0",
"next": "2.0.0-SNAPSHOT"
});

const version = await MyResolver.resolveVersion("next", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});

t.is(version, "2.0.0-SNAPSHOT", "Resolved version should be correct");

t.is(fetchAllVersionsStub.callCount, 1, "fetchAllVersions should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllVersions should be called with expected arguments");
t.is(fetchAllTagsStub.callCount, 1, "fetchAllTagsStub should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllTags should be called with expected arguments");
});


test.serial("AbstractResolver: Static resolveVersion resolves 'latest' using tags only " +
"when the resolver supports them", async (t) => {
const {MyResolver} = t.context;
const fetchAllVersionsStub = sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0", "2.0.0"]);
const fetchAllTagsStub = sinon.stub(MyResolver, "fetchAllTags")
.resolves(null);

// Resolver does not support tags (resolves with "null" instead of an object)
// 'latest' should resolve to the highest version available
const version1 = await MyResolver.resolveVersion("latest", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});
t.is(version1, "2.0.0", "Resolved version should be correct");

t.is(fetchAllTagsStub.callCount, 1, "fetchAllTagsStub should be called once");
t.deepEqual(fetchAllVersionsStub.getCall(0).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllTags should be called with expected arguments");

// Change behavior of Resolver to support tags, so that version should be used now
// instead of the highest version
fetchAllTagsStub.resolves({
"latest": "1.76.0"
});
const version2 = await MyResolver.resolveVersion("latest", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
});
t.is(version2, "1.76.0", "Resolved version should be correct");

t.is(fetchAllTagsStub.callCount, 2, "fetchAllTagsStub should be called twice");
t.deepEqual(fetchAllVersionsStub.getCall(1).args, [{
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}], "fetchAllTags should be called with expected arguments");
});

test.serial("AbstractResolver: Static resolveVersion throws error for empty string", async (t) => {
const {MyResolver} = t.context;
sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0"]);

const error = await t.throwsAsync(MyResolver.resolveVersion("", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));

t.is(error.message, `Framework version specifier "~1.75.0" is incorrect or not supported`);
t.is(error.message, `Framework version specifier "" is incorrect or not supported`);
});

t.is(fetchAllVersionsStub.callCount, 0, "fetchAllVersions should not be called");
test.serial("AbstractResolver: Static resolveVersion throws error for invalid tag name", async (t) => {
const {MyResolver} = t.context;
sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0"]);

const error = await t.throwsAsync(MyResolver.resolveVersion("%20", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));

t.is(error.message, `Framework version specifier "%20" is incorrect or not supported`);
});

test.serial("AbstractResolver: Static resolveVersion throws error for non-existing tag", async (t) => {
const {MyResolver} = t.context;
sinon.stub(MyResolver, "fetchAllVersions")
.returns(["1.75.0", "1.75.1", "1.76.0"]);
sinon.stub(MyResolver, "fetchAllTags")
.resolves({"latest": "1.76.0"});

const error = await t.throwsAsync(MyResolver.resolveVersion("this-tag-does-not-exist", {
cwd: "/cwd",
ui5HomeDir: "/ui5HomeDir"
}));

t.is(error.message, `Could not resolve framework version via tag 'this-tag-does-not-exist'. ` +
`Make sure the tag is available in the configured registry.`
);
});

test.serial("AbstractResolver: Static resolveVersion throws error for version not found", async (t) => {
Expand Down

0 comments on commit 2841004

Please sign in to comment.