This is an automated email from the ASF dual-hosted git repository.
mpochatkin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push:
new 1097ec3a005 IGNITE-24458 Java API compatibility extension (#6501)
1097ec3a005 is described below
commit 1097ec3a0052b201ff1c8e573d1b141043cbec92
Author: Vadim Kolodin <[email protected]>
AuthorDate: Mon Sep 8 09:40:45 2025 +0300
IGNITE-24458 Java API compatibility extension (#6501)
---
gradle/libs.versions.toml | 3 +
modules/compatibility-tests/build.gradle | 12 +-
.../ignite/internal/ItApiCompatibilityTest.java | 52 +++++++
.../org/apache/ignite/internal/Dependencies.java | 149 +++++++++++++++++++++
.../org/apache/ignite/internal/IgniteCluster.java | 47 +------
.../apache/ignite/internal/OldClientLoader.java | 2 +-
.../api/ApiCompatibilityExtension.java | 68 ++++++++++
.../compatibility/api/ApiCompatibilityTest.java | 77 +++++++++++
.../api/ApiCompatibilityTestInvocationContext.java | 43 ++++++
.../compatibility/api/CompatibilityChecker.java | 135 +++++++++++++++++++
.../compatibility/api/CompatibilityExtension.java | 55 ++++++++
.../compatibility/api/CompatibilityInput.java | 62 +++++++++
.../compatibility/api/CompatibilityOutput.java | 50 +++++++
.../internal/compatibility/api/MethodProvider.java | 104 ++++++++++++++
.../compatibility/api/TestNameFormatter.java | 41 ++++++
.../src/testFixtures/resources/igniteVersions.json | 2 +-
16 files changed, 850 insertions(+), 52 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f2e339ba74b..22c2cd19bb0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -94,6 +94,7 @@ testcontainers = "1.21.3"
gradleToolingApi = "8.6"
protobuf = "4.32.0"
cytodynamics = "0.2.0"
+japicmp = "0.23.1"
#Tools
pmdTool = "7.13.0"
@@ -308,6 +309,8 @@ protobuf-java = { module =
"com.google.protobuf:protobuf-java", version.ref = "p
cytodynamics-nucleus = { module =
"com.linkedin.cytodynamics:cytodynamics-nucleus", version.ref = "cytodynamics" }
+japicmp = { module = "com.github.siom79.japicmp:japicmp", version.ref =
"japicmp" }
+
[bundles]
junit = ["junit5-api", "junit5-impl", "junit5-params", "junit-pioneer"]
mockito = ["mockito-core", "mockito-junit"]
diff --git a/modules/compatibility-tests/build.gradle
b/modules/compatibility-tests/build.gradle
index a290e2df4e2..a33606ceedf 100644
--- a/modules/compatibility-tests/build.gradle
+++ b/modules/compatibility-tests/build.gradle
@@ -49,6 +49,7 @@ dependencies {
exclude group: 'org.ow2.asm', module: 'asm'
}
testFixturesImplementation libs.cytodynamics.nucleus
+ testFixturesImplementation libs.japicmp
testFixturesImplementation testFixtures(project(':ignite-core'))
testFixturesImplementation testFixtures(project(':ignite-runner'))
@@ -59,12 +60,12 @@ dependencies {
testFixturesImplementation project(':ignite-rest-api')
}
-private def resolveAllDependencies(String... dependencyNotations) {
+private def resolveAllDependencies(boolean transitive, String...
dependencyNotations) {
def dependencies = dependencyNotations.collect {
dependencies.create(it)
}
Configuration detached = configurations.detachedConfiguration(dependencies
as Dependency[])
- detached.transitive = true
+ detached.transitive = transitive
detached.attributes {
it.attribute(Category.CATEGORY_ATTRIBUTE,
objects.named(Category.class, Category.LIBRARY));
it.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class,
Usage.JAVA_RUNTIME));
@@ -90,7 +91,7 @@ def resolveCompatibilityTestDependencies =
tasks.register('resolveCompatibilityT
def versions = shouldTestAllVersions() ? versionsJson.versions :
versionsJson.versions.takeRight(2)
versions.each { version ->
versionsJson.artifacts.each { artifact ->
- resolveAllDependencies("$artifact:$version.version")
+ resolveAllDependencies(true, "$artifact:$version.version")
}
}
}
@@ -110,11 +111,12 @@ integrationTest.dependsOn
resolveCompatibilityTestDependencies
tasks.register('constructArgFile') {
doLast {
String[] dependencies = ((String)
project.property('dependencyNotation')).split(",")
- def jars = resolveAllDependencies(dependencies)
+ def transitive = project.property('argFileTransitive').toBoolean()
+ def jars = resolveAllDependencies(transitive, dependencies)
def jarFiles = files(jars)
def classPath = jarFiles.asPath
def classPathFilePath = project.property('argFilePath')
- def classPathOnly = project.property('classPathOnly')?.toBoolean() ?:
false
+ def classPathOnly =
project.property('argFileClassPathOnly').toBoolean()
def classPathFile = file(classPathFilePath)
classPathFile.withPrintWriter {
diff --git
a/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItApiCompatibilityTest.java
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItApiCompatibilityTest.java
new file mode 100644
index 00000000000..93d2af849f2
--- /dev/null
+++
b/modules/compatibility-tests/src/integrationTest/java/org/apache/ignite/internal/ItApiCompatibilityTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import org.apache.ignite.internal.compatibility.api.ApiCompatibilityTest;
+import org.apache.ignite.internal.compatibility.api.CompatibilityOutput;
+
+class ItApiCompatibilityTest {
+
+ // TODO resolve or explain exclusions
https://issues.apache.org/jira/browse/IGNITE-26365
+ @ApiCompatibilityTest(
+ newVersion = "3.1.0-SNAPSHOT",
+ oldVersions = "3.0.0",
+ exclude = ""
+ + "org.apache.ignite.Ignite#clusterNodes();" // deprecated
+ + "org.apache.ignite.Ignite#clusterNodesAsync();" //
deprecated
+ +
"org.apache.ignite.catalog.IgniteCatalog#dropTable(java.lang.String);" //
method abstract now default
+ +
"org.apache.ignite.catalog.IgniteCatalog#dropTableAsync(java.lang.String);" //
method abstract now default
+ +
"org.apache.ignite.catalog.IgniteCatalog#tableDefinition(java.lang.String);" //
method abstract now default
+ +
"org.apache.ignite.catalog.IgniteCatalog#tableDefinitionAsync(java.lang.String);"
// method abstract now default
+ + "org.apache.ignite.compute.ColocatedJobTarget;" //
method return type changed
+ + "org.apache.ignite.compute.TableJobTarget;" // method
return type changed
+ + "org.apache.ignite.lang.ColumnNotFoundException;" //
deprecated
+ + "org.apache.ignite.lang.IndexAlreadyExistsException;" //
deprecated
+ + "org.apache.ignite.lang.IndexNotFoundException;" //
deprecated
+ + "org.apache.ignite.lang.TableAlreadyExistsException;" //
deprecated
+ + "org.apache.ignite.lang.TableNotFoundException;" //
constructor removed
+ + "org.apache.ignite.lang.util.IgniteNameUtils;" //
methods removed, less accessible
+ + "org.apache.ignite.sql.IgniteSql;" // method abstract
now default
+ + "org.apache.ignite.table.DataStreamerTarget;" // method
abstract now default
+ + "org.apache.ignite.table.IgniteTables;" // method
abstract now default
+ + "org.apache.ignite.table.QualifiedName;" // now final,
serializable
+ + "org.apache.ignite.table.Table;" // method abstract now
default
+ )
+ void testApiModule(CompatibilityOutput output) {}
+
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/Dependencies.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/Dependencies.java
new file mode 100644
index 00000000000..c05192b9285
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/Dependencies.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.ignite.internal.logger.IgniteLogger;
+import org.apache.ignite.internal.logger.Loggers;
+import org.gradle.tooling.GradleConnectionException;
+import org.gradle.tooling.GradleConnector;
+import org.gradle.tooling.ProjectConnection;
+
+/**
+ * Utility class for resolving dependencies using Gradle.
+ */
+public class Dependencies {
+ private static final IgniteLogger LOG =
Loggers.forClass(Dependencies.class);
+
+ private Dependencies() {}
+
+ /**
+ * Provides the path to the dependency(s). Uses ; to separate jar files.
+ */
+ public static String path(String dependencyNotation, boolean transitive) {
+ LOG.info("Resolving path for dependency: " + dependencyNotation);
+ File projectRoot = getProjectRoot();
+
+ if (dependencyNotation.contains("SNAPSHOT")) {
+ String local = path(projectRoot, dependencyNotation);
+ if (!local.isEmpty()) {
+ return local;
+ }
+ }
+
+ try (ProjectConnection connection = GradleConnector.newConnector()
+ .forProjectDirectory(projectRoot)
+ .connect()
+ ) {
+ File file = constructArgFile(connection, dependencyNotation, true,
transitive);
+ return Files.readAllLines(file.toPath()).stream()
+ .map(String::trim)
+ .collect(Collectors.joining(";"));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static String path(File root, String dependencyNotation) {
+ String[] s = dependencyNotation.split(":");
+ if (s.length < 3) {
+ throw new RuntimeException("Supplied String module notation '" +
dependencyNotation
+ + "' is invalid. Example notations:
'org.gradle:gradle-core:2.2'");
+ }
+
+ String name = s[1];
+ String version = s[2];
+ String nameRegex = name + "-" + version + "\\.jar";
+
+ try (Stream<Path> stream = Files.walk(root.toPath())) {
+ return stream
+ .filter(Files::isRegularFile)
+ .map(Path::toAbsolutePath)
+ .filter(path ->
path.getFileName().toString().matches(nameRegex))
+ .map(Path::toString)
+ .findFirst()
+ .orElseGet(() -> {
+ LOG.info("Unable to find dependency in build dir " +
dependencyNotation);
+ return "";
+ });
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ static File constructArgFile(
+ ProjectConnection connection,
+ String dependencyNotation,
+ boolean classPathOnly
+ ) throws IOException {
+ return constructArgFile(connection, dependencyNotation, classPathOnly,
true);
+ }
+
+ static File constructArgFile(
+ ProjectConnection connection,
+ String dependencyNotation,
+ boolean classPathOnly,
+ boolean transitive
+ ) throws IOException {
+ File argFilePath = File.createTempFile("argFilePath", "");
+ argFilePath.deleteOnExit();
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+ try {
+ connection.newBuild()
+ .forTasks(":ignite-compatibility-tests:constructArgFile")
+ .withArguments(
+ "-PdependencyNotation=" + dependencyNotation,
+ "-PargFilePath=" + argFilePath,
+ "-PargFileTransitive=" + transitive,
+ "-PargFileClassPathOnly=" + classPathOnly
+ )
+ .setStandardOutput(baos)
+ .setStandardError(baos)
+ .run();
+ } catch (GradleConnectionException | IllegalStateException e) {
+ LOG.error("Failed to run constructArgFile task", e);
+ LOG.error("Gradle task output:" + System.lineSeparator() + baos);
+ throw new RuntimeException(e);
+ }
+
+ return argFilePath;
+ }
+
+ static File getProjectRoot() {
+ var absPath = new File("").getAbsolutePath();
+
+ // Find root by looking for "gradlew" file.
+ while (!new File(absPath, "gradlew").exists()) {
+ var parent = new File(absPath).getParentFile();
+ if (parent == null) {
+ throw new IllegalStateException("Could not find project root
with 'gradlew' file");
+ }
+ absPath = parent.getAbsolutePath();
+ }
+
+ return new File(absPath);
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/IgniteCluster.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/IgniteCluster.java
index 51fe9ec52c9..a56c8889ca0 100644
---
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/IgniteCluster.java
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/IgniteCluster.java
@@ -19,6 +19,8 @@ package org.apache.ignite.internal;
import static com.jayway.jsonpath.matchers.JsonPathMatchers.hasJsonPath;
import static java.util.stream.Collectors.toList;
+import static org.apache.ignite.internal.Dependencies.constructArgFile;
+import static org.apache.ignite.internal.Dependencies.getProjectRoot;
import static
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
import static
org.apache.ignite.internal.testframework.matchers.HttpResponseMatcher.hasStatusCode;
import static org.apache.ignite.internal.util.CollectionUtils.setListAtIndex;
@@ -29,7 +31,6 @@ import static org.hamcrest.Matchers.is;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
-import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URI;
@@ -61,7 +62,6 @@ import org.apache.ignite.internal.logger.IgniteLogger;
import org.apache.ignite.internal.logger.Loggers;
import org.apache.ignite.internal.rest.api.cluster.InitCommand;
import org.apache.ignite.internal.testframework.TestIgnitionManager;
-import org.gradle.tooling.GradleConnectionException;
import org.gradle.tooling.GradleConnector;
import org.gradle.tooling.ProjectConnection;
import org.gradle.tooling.model.build.BuildEnvironment;
@@ -326,49 +326,6 @@ public class IgniteCluster {
}
}
- private static File getProjectRoot() {
- var absPath = new File("").getAbsolutePath();
-
- // Find root by looking for "gradlew" file.
- while (!new File(absPath, "gradlew").exists()) {
- var parent = new File(absPath).getParentFile();
- if (parent == null) {
- throw new IllegalStateException("Could not find project root
with 'gradlew' file");
- }
- absPath = parent.getAbsolutePath();
- }
-
- return new File(absPath);
- }
-
- static File constructArgFile(
- ProjectConnection connection,
- String dependencyNotation,
- boolean classPathOnly) throws IOException {
- File argFilePath = File.createTempFile("argFilePath", "");
- argFilePath.deleteOnExit();
-
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try {
- connection.newBuild()
- .forTasks(":ignite-compatibility-tests:constructArgFile")
- .withArguments(
- "-PdependencyNotation=" + dependencyNotation,
- "-PargFilePath=" + argFilePath,
- "-PclassPathOnly=" + classPathOnly
- )
- .setStandardOutput(baos)
- .setStandardError(baos)
- .run();
- } catch (GradleConnectionException | IllegalStateException e) {
- LOG.error("Failed to run constructArgFile task", e);
- LOG.error("Gradle task output:" + System.lineSeparator() + baos);
- throw new RuntimeException(e);
- }
-
- return argFilePath;
- }
-
private HttpRequest post(String path, String body) {
return newBuilder(path)
.header("content-type", "application/json")
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/OldClientLoader.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/OldClientLoader.java
index 3116dacbb8f..991c8702aa7 100644
---
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/OldClientLoader.java
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/OldClientLoader.java
@@ -50,7 +50,7 @@ public class OldClientLoader {
.forProjectDirectory(Path.of("..", "..").normalize().toFile())
.connect()
) {
- File argFile = IgniteCluster.constructArgFile(connection,
"org.apache.ignite:ignite-client:" + igniteVersion, true);
+ File argFile = Dependencies.constructArgFile(connection,
"org.apache.ignite:ignite-client:" + igniteVersion, true);
List<URI> classpath = Files.readAllLines(argFile.toPath())
.stream()
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityExtension.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityExtension.java
new file mode 100644
index 00000000000..c9c71c86000
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityExtension.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import static java.util.stream.Collectors.toList;
+import static
org.apache.ignite.internal.compatibility.api.MethodProvider.looksLikeMethodName;
+import static
org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
+import static org.junit.platform.commons.support.AnnotationSupport.isAnnotated;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.ignite.internal.IgniteVersions;
+import org.apache.ignite.internal.IgniteVersions.Version;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
+import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider;
+
+class ApiCompatibilityExtension implements
TestTemplateInvocationContextProvider {
+ private static final String[] MODULES_ALL;
+
+ @Override
+ public boolean supportsTestTemplate(ExtensionContext context) {
+ return isAnnotated(context.getTestMethod(),
ApiCompatibilityTest.class);
+ }
+
+ @Override
+ public Stream<TestTemplateInvocationContext>
provideTestTemplateInvocationContexts(ExtensionContext context) {
+ ApiCompatibilityTest a =
findAnnotation(context.getRequiredTestMethod(),
ApiCompatibilityTest.class).orElseThrow();
+
+ List<String> oldVersions;
+ if (a.oldVersions().length == 0) {
+ oldVersions =
IgniteVersions.INSTANCE.versions().stream().map(Version::version).distinct().collect(toList());
+ } else if (a.oldVersions().length == 1 &&
looksLikeMethodName(a.oldVersions()[0])) {
+ oldVersions = MethodProvider.provideArguments(context,
a.oldVersions()[0]);
+ } else {
+ oldVersions = Arrays.asList(a.oldVersions());
+ }
+
+ // to reduce test time - need to resolve dependencies paths here once
for all modules
+ String[] modules = a.modules().length == 0 ? MODULES_ALL : a.modules();
+
+ return Arrays.stream(modules)
+ .flatMap(module -> oldVersions.stream().map(v -> new
CompatibilityInput(module, v, a)))
+ .map(input -> new ApiCompatibilityTestInvocationContext(input,
new TestNameFormatter(context, input)));
+ }
+
+ static {
+ MODULES_ALL = new String[] { // could be resolved by gradle or moved
to public annotation after stabilization
+ "ignite-api"
+ };
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTest.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTest.java
new file mode 100644
index 00000000000..250c0aa0a53
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * {@code @ApiCompatibilityTest} is used to signal that the annotated method
is a API compatibility test between two versions of a module.
+ *
+ * <p>This annotation is somewhat similar to {@code @ParameterizedTest}, as in
it also can run test multiple times per module.
+ *
+ * <p>Methods annotated with this annotation should not be annotated with
{@code Test}.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@TestTemplate
+@ExtendWith(ApiCompatibilityExtension.class)
+public @interface ApiCompatibilityTest {
+
+ /**
+ * Module old versions to check compatibility against.
+ *
+ * <p>If empty, uses {@code versions.json}.
+ *
+ * <p>If single value is given and it looks like a static factory method,
this method is used to provide versions.
+ * Factory methods in external classes must be referenced by
+ * <em>fully qualified method name</em> — for example,
+ * {@code "com.example.StringsProviders#blankStrings"} or
+ * {@code "com.example.TopLevelClass$NestedClass#classMethod"} for a
factory
+ * method in a static nested class.
+ */
+ String[] oldVersions() default {};
+
+ /**
+ * Module new version to check compatibility for.
+ */
+ String newVersion();
+
+ /**
+ * List of modules to check. If empty, all modules are checked.
+ */
+ String[] modules() default { "ignite-api" };
+
+ /**
+ * Semicolon separated list of elements to exclude in the form
+ * {@code package.Class#classMember}, * can be used as wildcard.
Annotations
+ * are given as FQN starting with @.
+ * <br>Examples:<br>
+ * {@code
mypackage;my.Class;other.Class#method(int,long);foo.Class#field;@my.Annotation}.
+ */
+ String exclude() default "";
+
+ /**
+ * Exit with an error if any incompatibility is detected.
+ */
+ boolean errorOnIncompatibility() default true;
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTestInvocationContext.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTestInvocationContext.java
new file mode 100644
index 00000000000..f86426a40aa
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/ApiCompatibilityTestInvocationContext.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import java.util.List;
+import org.junit.jupiter.api.extension.Extension;
+import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
+
+class ApiCompatibilityTestInvocationContext implements
TestTemplateInvocationContext {
+
+ private final CompatibilityInput input;
+ private final TestNameFormatter formatter;
+
+ ApiCompatibilityTestInvocationContext(CompatibilityInput input,
TestNameFormatter formatter) {
+ this.input = input;
+ this.formatter = formatter;
+ }
+
+ @Override
+ public String getDisplayName(int invocationIndex) {
+ return formatter.format(invocationIndex);
+ }
+
+ @Override
+ public List<Extension> getAdditionalExtensions() {
+ return List.of(new CompatibilityExtension(input));
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityChecker.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityChecker.java
new file mode 100644
index 00000000000..5ee78aeeb0c
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityChecker.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import japicmp.cli.CliParser;
+import japicmp.cli.JApiCli;
+import japicmp.cmp.JarArchiveComparator;
+import japicmp.cmp.JarArchiveComparatorOptions;
+import japicmp.config.Options;
+import japicmp.exception.JApiCmpException;
+import japicmp.model.JApiClass;
+import japicmp.output.html.HtmlOutput;
+import japicmp.output.html.HtmlOutputGenerator;
+import japicmp.output.html.HtmlOutputGeneratorOptions;
+import japicmp.output.incompatible.IncompatibleErrorOutput;
+import japicmp.output.markdown.MarkdownOutputGenerator;
+import japicmp.output.semver.SemverOut;
+import japicmp.output.stdout.StdoutOutputGenerator;
+import japicmp.output.xml.XmlOutput;
+import japicmp.output.xml.XmlOutputGenerator;
+import japicmp.output.xml.XmlOutputGeneratorOptions;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import org.apache.ignite.internal.Dependencies;
+import org.apache.ignite.internal.util.ArrayUtils;
+
+class CompatibilityChecker {
+
+ /**
+ * Runs japicmp with given input and returns the output.
+ *
+ * @see <a href="https://siom79.github.io/japicmp/CliTool.html">japicmp
options</a>
+ */
+ static CompatibilityOutput check(CompatibilityInput input) {
+ String[] args = {
+ "--old", Dependencies.path(input.oldVersionNotation(), false),
+ "--new", Dependencies.path(input.newVersionNotation(), false),
+ "--exclude", input.exclude(),
+ "--markdown",
+ "--only-incompatible",
+ "--ignore-missing-classes",
+ "--html-file", "build/reports/" + input.module() +
"-japicmp.html",
+ "--xml-file", "build/reports/" + input.module() +
"-japicmp.xml",
+ };
+
+ if (input.errorOnIncompatibility()) {
+ args = ArrayUtils.concat(args,
+ "--error-on-source-incompatibility"
+ );
+ }
+
+ Options options = new CliParser().parse(args);
+ JarArchiveComparator jarArchiveComparator = new
JarArchiveComparator(JarArchiveComparatorOptions.of(options));
+ List<JApiClass> javaApiClasses =
jarArchiveComparator.compare(options.getOldArchives(),
options.getNewArchives());
+ return new CompatibilityOutput(options, javaApiClasses,
jarArchiveComparator);
+ }
+
+ static void generateOutput(CompatibilityOutput output) {
+ generateOutput(output.options(), output.javaApiClasses(),
output.jarArchiveComparator());
+ // use custom output generator to throw exceptions and list of
incompatibilities
+ }
+
+ /**
+ * Origin method is private.
+ *
+ * @see JApiCli#generateOutput(Options, List, JarArchiveComparator)
+ */
+ private static void generateOutput(Options options, List<JApiClass>
javaApiClasses, JarArchiveComparator jarArchiveComparator) {
+ if (options.isSemanticVersioning()) {
+ SemverOut semverOut = new SemverOut(options, javaApiClasses);
+ String output = semverOut.generate();
+ System.out.println(output);
+ return;
+ }
+ SemverOut semverOut = new SemverOut(options, javaApiClasses);
+ if (options.getXmlOutputFile().isPresent()) {
+ XmlOutputGeneratorOptions xmlOutputGeneratorOptions = new
XmlOutputGeneratorOptions();
+ xmlOutputGeneratorOptions.setCreateSchemaFile(true);
+
xmlOutputGeneratorOptions.setSemanticVersioningInformation(semverOut.generate());
+ XmlOutputGenerator xmlGenerator = new
XmlOutputGenerator(javaApiClasses, options, xmlOutputGeneratorOptions);
+ try (XmlOutput xmlOutput = xmlGenerator.generate()) {
+ XmlOutputGenerator.writeToFiles(options, xmlOutput);
+ } catch (Exception e) {
+ throw new
JApiCmpException(JApiCmpException.Reason.IoException, "Could not write XML
file: " + e.getMessage(), e);
+ }
+ }
+ if (options.getHtmlOutputFile().isPresent()) {
+ HtmlOutputGeneratorOptions htmlOutputGeneratorOptions = new
HtmlOutputGeneratorOptions();
+
htmlOutputGeneratorOptions.setSemanticVersioningInformation(semverOut.generate());
+ HtmlOutputGenerator outputGenerator = new
HtmlOutputGenerator(javaApiClasses, options, htmlOutputGeneratorOptions);
+ HtmlOutput htmlOutput = outputGenerator.generate();
+ try {
+ Files.write(Paths.get(options.getHtmlOutputFile().get()),
htmlOutput.getHtml().getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new
JApiCmpException(JApiCmpException.Reason.IoException, "Could not write HTML
file: " + e.getMessage(), e);
+ }
+ }
+ if (options.isMarkdown()) {
+ MarkdownOutputGenerator markdownOutputGenerator = new
MarkdownOutputGenerator(options, javaApiClasses);
+ String output = markdownOutputGenerator.generate();
+ System.out.println(output);
+ } else {
+ StdoutOutputGenerator stdoutOutputGenerator = new
StdoutOutputGenerator(options, javaApiClasses);
+ String output = stdoutOutputGenerator.generate();
+ System.out.println(output);
+ }
+ if (options.isErrorOnBinaryIncompatibility()
+ || options.isErrorOnSourceIncompatibility()
+ || options.isErrorOnExclusionIncompatibility()
+ || options.isErrorOnModifications()
+ || options.isErrorOnSemanticIncompatibility()) {
+ IncompatibleErrorOutput errorOutput = new
IncompatibleErrorOutput(options, javaApiClasses, jarArchiveComparator);
+ errorOutput.generate();
+ }
+ }
+
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityExtension.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityExtension.java
new file mode 100644
index 00000000000..84fa9b28c49
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityExtension.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.ParameterResolver;
+
+class CompatibilityExtension implements BeforeEachCallback, ParameterResolver {
+ private static final Namespace NAMESPACE =
Namespace.create(CompatibilityExtension.class);
+ private static final String OUTPUT_KEY = "compatibilityOutput";
+ private final CompatibilityInput input;
+
+ CompatibilityExtension(CompatibilityInput input) {
+ this.input = input;
+ }
+
+ @Override
+ public void beforeEach(ExtensionContext context) {
+ CompatibilityOutput c = CompatibilityChecker.check(input);
+ context.getStore(NAMESPACE).put(OUTPUT_KEY, c);
+ CompatibilityChecker.generateOutput(c);
+ }
+
+ @Override
+ public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext context)
+ throws ParameterResolutionException {
+ Class<?> type = parameterContext.getParameter().getType();
+ return type == CompatibilityOutput.class;
+ }
+
+ @Override
+ public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext context)
+ throws ParameterResolutionException {
+ return context.getStore(NAMESPACE).get(OUTPUT_KEY,
CompatibilityOutput.class);
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityInput.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityInput.java
new file mode 100644
index 00000000000..b15c857b5d9
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityInput.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+class CompatibilityInput {
+ private final String module;
+ private final String oldVersion;
+ private final String newVersion;
+ private final String exclude;
+ private final boolean errorOnIncompatibility;
+
+ CompatibilityInput(String module, String oldVersion, ApiCompatibilityTest
annotation) {
+ this.module = module;
+ this.oldVersion = oldVersion;
+ this.newVersion = annotation.newVersion();
+ this.exclude = annotation.exclude();
+ this.errorOnIncompatibility = annotation.errorOnIncompatibility();
+ }
+
+ String module() {
+ return module;
+ }
+
+ String oldVersion() {
+ return oldVersion;
+ }
+
+ String newVersion() {
+ return newVersion;
+ }
+
+ String oldVersionNotation() {
+ return "org.apache.ignite:" + module + ":" + oldVersion;
+ }
+
+ String newVersionNotation() {
+ return "org.apache.ignite:" + module + ":" + newVersion;
+ }
+
+ String exclude() {
+ return exclude;
+ }
+
+ boolean errorOnIncompatibility() {
+ return errorOnIncompatibility;
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityOutput.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityOutput.java
new file mode 100644
index 00000000000..f29419acab4
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/CompatibilityOutput.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import japicmp.cmp.JarArchiveComparator;
+import japicmp.config.Options;
+import japicmp.model.JApiClass;
+import java.util.List;
+
+/**
+ * Japicmp comparison output. Can be handy for custom {@link
japicmp.output.OutputGenerator}
+ */
+public class CompatibilityOutput {
+ private final Options options;
+ private final List<JApiClass> javaApiClasses;
+ private final JarArchiveComparator jarArchiveComparator;
+
+ CompatibilityOutput(Options options, List<JApiClass> javaApiClasses,
JarArchiveComparator jarArchiveComparator) {
+ this.options = options;
+ this.javaApiClasses = javaApiClasses;
+ this.jarArchiveComparator = jarArchiveComparator;
+ }
+
+ Options options() {
+ return options;
+ }
+
+ List<JApiClass> javaApiClasses() {
+ return javaApiClasses;
+ }
+
+ JarArchiveComparator jarArchiveComparator() {
+ return jarArchiveComparator;
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/MethodProvider.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/MethodProvider.java
new file mode 100644
index 00000000000..77bd367fd4d
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/MethodProvider.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import static java.lang.String.format;
+import static
org.junit.platform.commons.util.CollectionUtils.isConvertibleToStream;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.platform.commons.util.ClassLoaderUtils;
+import org.junit.platform.commons.util.CollectionUtils;
+import org.junit.platform.commons.util.Preconditions;
+import org.junit.platform.commons.util.ReflectionUtils;
+
+/**
+ * Simplified version of {@link
org.junit.jupiter.params.provider.MethodArgumentsProvider}.
+ */
+class MethodProvider {
+ private static final Predicate<Method> isFactoryMethod =
+ method -> isConvertibleToStream(method.getReturnType());
+
+ static <T> List<T> provideArguments(ExtensionContext context, String
methodName) {
+ Class<?> testClass = context.getRequiredTestClass();
+ Optional<Method> testMethod = context.getTestMethod();
+ Object testInstance = context.getTestInstance().orElse(null);
+
+ return Stream.of(methodName)
+ .map(factoryMethodName -> findFactoryMethod(testClass,
testMethod, factoryMethodName))
+ .map(factoryMethod ->
context.getExecutableInvoker().invoke(factoryMethod, testInstance))
+ .flatMap(CollectionUtils::toStream)
+ .map(o -> (T) o)
+ .collect(Collectors.toList());
+ }
+
+ static boolean looksLikeMethodName(String factoryMethodName) {
+ if (factoryMethodName.contains("#")) {
+ return true;
+ }
+ if (factoryMethodName.matches("^[a-zA-Z0-9_]*$")) {
+ return true;
+ }
+ return false;
+ }
+
+ private static Method findFactoryMethod(Class<?> testClass,
Optional<Method> testMethod, String factoryMethodName) {
+ String originalFactoryMethodName = factoryMethodName;
+
+ // Convert local factory method name to fully qualified method name.
+ if (looksLikeMethodName(factoryMethodName) &&
!factoryMethodName.contains("#")) {
+ factoryMethodName = testClass.getName() + "#" + factoryMethodName;
+ }
+
+ Method factoryMethod =
findFactoryMethodByFullyQualifiedName(testClass, testMethod, factoryMethodName);
+
+ Preconditions.condition(isFactoryMethod.test(factoryMethod), () ->
format(
+ "Could not find valid factory method [%s] for test class [%s]
but found the following invalid candidate: %s",
+ originalFactoryMethodName, testClass.getName(),
factoryMethod));
+
+ return factoryMethod;
+ }
+
+ private static @Nullable Method findFactoryMethodByFullyQualifiedName(
+ Class<?> testClass,
+ Optional<Method> testMethod,
+ String fullyQualifiedMethodName
+ ) {
+ String[] methodParts =
ReflectionUtils.parseFullyQualifiedMethodName(fullyQualifiedMethodName);
+ String className = methodParts[0];
+ String methodName = methodParts[1];
+ String methodParameters = methodParts[2];
+ ClassLoader classLoader = ClassLoaderUtils.getClassLoader(testClass);
+ Class<?> clazz = ReflectionUtils.loadRequiredClass(className,
classLoader);
+
+ // Attempt to find an exact match first.
+ Method factoryMethod = ReflectionUtils.findMethod(clazz, methodName,
methodParameters).orElse(null);
+
+ if (factoryMethod != null) {
+ return factoryMethod;
+ }
+
+ return null;
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/TestNameFormatter.java
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/TestNameFormatter.java
new file mode 100644
index 00000000000..4d9c9a63679
--- /dev/null
+++
b/modules/compatibility-tests/src/testFixtures/java/org/apache/ignite/internal/compatibility/api/TestNameFormatter.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.compatibility.api;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+class TestNameFormatter {
+ private static final String PATTERN = "[{module}] of '{newVersion}' with
previous '{oldVersion}'";
+ private final String displayName;
+ private final CompatibilityInput input;
+
+ TestNameFormatter(ExtensionContext extensionContext, CompatibilityInput
input) {
+ this.displayName = extensionContext.getDisplayName();
+ this.input = input;
+ }
+
+ String format(int invocationIndex) {
+ return PATTERN
+ .replace("{displayName}", displayName)
+ .replace("{index}", String.valueOf(invocationIndex))
+ .replace("{module}", input.module())
+ .replace("{oldVersion}", input.oldVersion())
+ .replace("{newVersion}", input.newVersion())
+ ;
+ }
+}
diff --git
a/modules/compatibility-tests/src/testFixtures/resources/igniteVersions.json
b/modules/compatibility-tests/src/testFixtures/resources/igniteVersions.json
index a0d2407b4a6..54c3d59bc4f 100644
--- a/modules/compatibility-tests/src/testFixtures/resources/igniteVersions.json
+++ b/modules/compatibility-tests/src/testFixtures/resources/igniteVersions.json
@@ -20,4 +20,4 @@
"version": "3.0.0"
}
]
-}
\ No newline at end of file
+}