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> &mdash; 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
+}

Reply via email to