/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ package org.opensearch.gradle.pluginzip; import org.gradle.api.Project; import org.gradle.testfixtures.ProjectBuilder; import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.GradleRunner; import org.gradle.testkit.runner.UnexpectedBuildFailure; import org.opensearch.gradle.test.GradleUnitTestCase; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.File; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.Writer; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import static org.gradle.testkit.runner.TaskOutcome.SUCCESS; import org.apache.maven.model.Model; import org.apache.maven.model.io.xpp3.MavenXpp3Reader; import org.codehaus.plexus.util.xml.pull.XmlPullParserException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; public class PublishTests extends GradleUnitTestCase { private TemporaryFolder projectDir; private static final String TEMPLATE_RESOURCE_FOLDER = "pluginzip"; private final String PROJECT_NAME = "sample-plugin"; private final String ZIP_PUBLISH_TASK = "publishPluginZipPublicationToZipStagingRepository"; @Before public void setUp() throws IOException { projectDir = new TemporaryFolder(); projectDir.create(); } @After public void tearDown() { projectDir.delete(); } /** * This test is used to verify that adding the 'opensearch.pluginzip' to the project * adds some other transitive plugins and tasks under the hood. This is basically * a behavioral test of the {@link Publish#apply(Project)} method. * * This is equivalent of having a build.gradle script with just the following section: *
     *     plugins {
     *       id 'opensearch.pluginzip'
     *     }
     * 
     */
    @Test
    public void applyZipPublicationPluginNoConfig() {
        // All we do here is creating an empty project and applying the Publish plugin.
        Project project = ProjectBuilder.builder().build();
        project.getPluginManager().apply(Publish.class);
        // WARNING: =====================================================================
        // All the following tests will work only before the gradle project is evaluated.
        // There are some methods that will cause the project to be evaluated, such as:
        // project.getTasksByName()
        // After the project is evaluated there are more tasks found in the project, like
        // the [assemble, build, ...] and other standard tasks.
        // This can potentially break in future gradle versions (?)
        // ===============================================================================
        assertEquals(
            "The Publish plugin is applied which adds total of five tasks from Nebula and MavenPublishing plugins.",
            5,
            project.getTasks().size()
        );
        // Tasks applied from "com.netflix.nebula.maven-base-publish"
        assertTrue(
            project.getTasks()
                .findByName("generateMetadataFileForNebulaPublication") instanceof org.gradle.api.publish.tasks.GenerateModuleMetadata
        );
        assertTrue(
            project.getTasks()
                .findByName("generatePomFileForNebulaPublication") instanceof org.gradle.api.publish.maven.tasks.GenerateMavenPom
        );
        assertTrue(
            project.getTasks()
                .findByName("publishNebulaPublicationToMavenLocal") instanceof org.gradle.api.publish.maven.tasks.PublishToMavenLocal
        );
        // Tasks applied from MavenPublishPlugin
        assertTrue(project.getTasks().findByName("publishToMavenLocal") instanceof org.gradle.api.DefaultTask);
        assertTrue(project.getTasks().findByName("publish") instanceof org.gradle.api.DefaultTask);
        // And we miss the pluginzip publication task (because no publishing was defined for it)
        assertNull(project.getTasks().findByName(ZIP_PUBLISH_TASK));
        // We have the following publishing plugins
        assertEquals(4, project.getPlugins().size());
        // ... of the following types:
        assertNotNull(
            "Project is expected to have OpenSearch pluginzip Publish plugin",
            project.getPlugins().findPlugin(org.opensearch.gradle.pluginzip.Publish.class)
        );
        assertNotNull(
            "Project is expected to have MavenPublishPlugin (applied from OpenSearch pluginzip plugin)",
            project.getPlugins().findPlugin(org.gradle.api.publish.maven.plugins.MavenPublishPlugin.class)
        );
        assertNotNull(
            "Project is expected to have Publishing plugin (applied from MavenPublishPublish plugin)",
            project.getPlugins().findPlugin(org.gradle.api.publish.plugins.PublishingPlugin.class)
        );
        assertNotNull(
            "Project is expected to have nebula MavenNebulaPublishPlugin plugin (applied from OpenSearch pluginzip plugin)",
            project.getPlugins().findPlugin(nebula.plugin.publishing.maven.MavenNebulaPublishPlugin.class)
        );
    }
    /**
     * Verify that if the zip publication is configured then relevant tasks are chained correctly.
     * This test that the dependsOn() is applied correctly.
     */
    @Test
    public void applyZipPublicationPluginWithConfig() throws IOException, URISyntaxException, InterruptedException {
        /* -------------------------------
        // The ideal approach would be to create a project (via ProjectBuilder) with publishzip plugin,
        // have it evaluated (API call) and then check if there are tasks that the plugin uses to hookup into
        // and how these tasks are chained. The problem is that there is a known gradle issue (#20301) that does
        // not allow for it ATM. If, however, it is fixed in the future the following is the code that can
        // be used...
        Project project = ProjectBuilder.builder().build();
        project.getPluginManager().apply(Publish.class);
        // add publications via API
        // evaluate the project
        ((DefaultProject)project).evaluate();
        // - Check that "validatePluginZipPom" and/or "publishPluginZipPublicationToZipStagingRepository"
        //   tasks have dependencies on "generatePomFileForNebulaPublication".
        // - Check that there is the staging repository added.
        // However, due to known issue(1): https://github.com/gradle/gradle/issues/20301
        // it is impossible to reach to individual tasks and work with them.
        // (1): https://docs.gradle.org/7.4/release-notes.html#known-issues
        // I.e.: The following code throws exception, basically any access to individual tasks fails.
        project.getTasks().getByName("validatePluginZipPom");
         ------------------------------- */
        // Instead, we run the gradle project via GradleRunner (this way we get fully evaluated project)
        // and using the minimal possible configuration (missingPOMEntity) we test that as soon as the zip publication
        // configuration is specified then all the necessary tasks are hooked up and executed correctly.
        // However, this does not test execution order of the tasks.
        GradleRunner runner = prepareGradleRunnerFromTemplate("missingPOMEntity.gradle", ZIP_PUBLISH_TASK/*, "-m"*/);
        BuildResult result = runner.build();
        assertEquals(SUCCESS, result.task(":" + "bundlePlugin").getOutcome());
        assertEquals(SUCCESS, result.task(":" + "generatePomFileForNebulaPublication").getOutcome());
        assertEquals(SUCCESS, result.task(":" + "generatePomFileForPluginZipPublication").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
    }
    /**
     * If the plugin is used but relevant publication is not defined then a message is printed.
     */
    @Test
    public void missingPublications() throws IOException, URISyntaxException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("missingPublications.gradle", "build", "-m");
        BuildResult result = runner.build();
        assertTrue(result.getOutput().contains("Plugin 'opensearch.pluginzip' is applied but no 'pluginZip' publication is defined."));
    }
    @Test
    public void missingGroupValue() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("missingGroupValue.gradle", "build", ZIP_PUBLISH_TASK);
        Exception e = assertThrows(UnexpectedBuildFailure.class, runner::build);
        assertTrue(e.getMessage().contains("Invalid publication 'pluginZip': groupId cannot be empty."));
    }
    /**
     * This would be the most common use case where user declares Maven publication entity with minimal info
     * and the resulting POM file will use artifactId, groupId and version values based on the Gradle project object.
     */
    @Test
    public void useDefaultValues() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("useDefaultValues.gradle", "build", ZIP_PUBLISH_TASK);
        BuildResult result = runner.build();
        /** Check if build and {@value ZIP_PUBLISH_TASK} tasks have run well */
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
        // check if both the zip and pom files have been published to local staging repo
        assertTrue(
            new File(
                projectDir.getRoot(),
                String.join(
                    File.separator,
                    "build",
                    "local-staging-repo",
                    "org",
                    "custom",
                    "group",
                    PROJECT_NAME,
                    "2.0.0.0",
                    PROJECT_NAME + "-2.0.0.0.pom"
                )
            ).exists()
        );
        assertTrue(
            new File(
                projectDir.getRoot(),
                String.join(
                    File.separator,
                    "build",
                    "local-staging-repo",
                    "org",
                    "custom",
                    "group",
                    PROJECT_NAME,
                    "2.0.0.0",
                    PROJECT_NAME + "-2.0.0.0.zip"
                )
            ).exists()
        );
        // Parse the maven file and validate default values
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "org",
                        "custom",
                        "group",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        assertEquals(model.getVersion(), "2.0.0.0");
        assertEquals(model.getGroupId(), "org.custom.group");
        assertEquals(model.getArtifactId(), PROJECT_NAME);
        assertNull(model.getName());
        assertNull(model.getDescription());
        assertEquals(model.getUrl(), "https://github.com/doe/sample-plugin");
    }
    /**
     * If the `group` is defined in gradle's allprojects section then it does not have to defined in publications.
     */
    @Test
    public void allProjectsGroup() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("allProjectsGroup.gradle", "build", ZIP_PUBLISH_TASK);
        BuildResult result = runner.build();
        /** Check if build and {@value ZIP_PUBLISH_TASK} tasks have run well */
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
        // Parse the maven file and validate default values
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "org",
                        "opensearch",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        assertEquals(model.getVersion(), "2.0.0.0");
        assertEquals(model.getGroupId(), "org.opensearch");
    }
    /**
     * The groupId value can be defined on several levels. This tests that the most internal level outweighs other levels.
     */
    @Test
    public void groupPriorityLevel() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("groupPriorityLevel.gradle", "build", ZIP_PUBLISH_TASK);
        BuildResult result = runner.build();
        /** Check if build and {@value ZIP_PUBLISH_TASK} tasks have run well */
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
        // Parse the maven file and validate default values
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "level",
                        "3",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        assertEquals(model.getVersion(), "2.0.0.0");
        assertEquals(model.getGroupId(), "level.3");
    }
    /**
     * In this case the Publication entity is completely missing but still the POM file is generated using the default
     * values including the groupId and version values obtained from the Gradle project object.
     */
    @Test
    public void missingPOMEntity() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("missingPOMEntity.gradle", "build", ZIP_PUBLISH_TASK);
        BuildResult result = runner.build();
        /** Check if build and {@value ZIP_PUBLISH_TASK} tasks have run well */
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
        // Parse the maven file and validate it
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "org",
                        "custom",
                        "group",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        assertEquals(model.getArtifactId(), PROJECT_NAME);
        assertEquals(model.getGroupId(), "org.custom.group");
        assertEquals(model.getVersion(), "2.0.0.0");
        assertEquals(model.getPackaging(), "zip");
        assertNull(model.getName());
        assertNull(model.getDescription());
        assertEquals(0, model.getDevelopers().size());
        assertEquals(0, model.getContributors().size());
        assertEquals(0, model.getLicenses().size());
    }
    /**
     * In some cases we need the POM groupId value to be different from the Gradle "project.group" value hence we
     * allow for groupId customization (it will override whatever the Gradle "project.group" value is).
     */
    @Test
    public void customizedGroupValue() throws IOException, URISyntaxException, XmlPullParserException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("customizedGroupValue.gradle", "build", ZIP_PUBLISH_TASK);
        BuildResult result = runner.build();
        /** Check if build and {@value ZIP_PUBLISH_TASK} tasks have run well */
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + ZIP_PUBLISH_TASK).getOutcome());
        // Parse the maven file and validate the groupID
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "I",
                        "am",
                        "customized",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        assertEquals(model.getGroupId(), "I.am.customized");
    }
    /**
     * If the customized groupId value is invalid (from the Maven POM perspective) then we need to be sure it is
     * caught and reported properly.
     */
    @Test
    public void customizedInvalidGroupValue() throws IOException, URISyntaxException {
        GradleRunner runner = prepareGradleRunnerFromTemplate("customizedInvalidGroupValue.gradle", "build", ZIP_PUBLISH_TASK);
        Exception e = assertThrows(UnexpectedBuildFailure.class, runner::build);
        assertTrue(
            e.getMessage().contains("Invalid publication 'pluginZip': groupId ( ) is not a valid Maven identifier ([A-Za-z0-9_\\-.]+).")
        );
    }
    /**
     * This test verifies that use of the pluginZip does not clash with other maven publication plugins.
     * It covers the case when user calls the "publishToMavenLocal" task.
     */
    @Test
    public void publishToMavenLocal() throws IOException, URISyntaxException, XmlPullParserException {
        // By default, the "publishToMavenLocal" publishes artifacts to a local m2 repo, typically
        // found in `~/.m2/repository`. But this is not practical for this unit test at all. We need to point
        // the 'maven-publish' plugin to a custom m2 repo located in temporary directory associated with this
        // test case instead.
        //
        // According to Gradle documentation this should be possible by proper configuration of the publishing
        // task (https://docs.gradle.org/current/userguide/publishing_maven.html#publishing_maven:install).
        // But for some reason this never worked as expected and artifacts created during this test case
        // were always pushed into the default local m2 repository (ie: `~/.m2/repository`).
        // The only workaround that seems to work is to pass "-Dmaven.repo.local" property via runner argument.
        // (Kudos to: https://stackoverflow.com/questions/72265294/gradle-publishtomavenlocal-specify-custom-directory)
        //
        // The temporary directory that is used as the local m2 repository is created via in task "prepareLocalMVNRepo".
        GradleRunner runner = prepareGradleRunnerFromTemplate(
            "publishToMavenLocal.gradle",
            String.join(File.separator, "-Dmaven.repo.local=" + projectDir.getRoot(), "build", "local-staging-repo"),
            "build",
            "prepareLocalMVNRepo",
            "publishToMavenLocal"
        );
        BuildResult result = runner.build();
        assertEquals(SUCCESS, result.task(":" + "build").getOutcome());
        assertEquals(SUCCESS, result.task(":" + "publishToMavenLocal").getOutcome());
        // Parse the maven file and validate it
        MavenXpp3Reader reader = new MavenXpp3Reader();
        Model model = reader.read(
            new FileReader(
                new File(
                    projectDir.getRoot(),
                    String.join(
                        File.separator,
                        "build",
                        "local-staging-repo",
                        "org",
                        "custom",
                        "group",
                        PROJECT_NAME,
                        "2.0.0.0",
                        PROJECT_NAME + "-2.0.0.0.pom"
                    )
                )
            )
        );
        // The "publishToMavenLocal" task will run ALL maven publications, hence we can expect the ZIP publication
        // present as well: https://docs.gradle.org/current/userguide/publishing_maven.html#publishing_maven:tasks
        assertEquals(model.getArtifactId(), PROJECT_NAME);
        assertEquals(model.getGroupId(), "org.custom.group");
        assertEquals(model.getVersion(), "2.0.0.0");
        assertEquals(model.getPackaging(), "zip");
        // We have two publications in the build.gradle file, both are "MavenPublication" based.
        // Both the mavenJava and pluginZip publications publish to the same location (coordinates) and
        // artifacts (the POM file) overwrite each other. However, we can verify that the Zip plugin is
        // the last one and "wins" over the mavenJava.
        assertEquals(model.getDescription(), "pluginZip publication");
    }
    /**
     * A helper method for use cases
     *
     * @param templateName The name of the file (from "pluginzip" folder) to use as a build.gradle for the test
     * @param gradleArguments Optional CLI parameters to pass into Gradle runner
     */
    private GradleRunner prepareGradleRunnerFromTemplate(String templateName, String... gradleArguments) throws IOException,
        URISyntaxException {
        useTemplateFile(projectDir.newFile("build.gradle"), templateName);
        prepareGradleFilesAndSources();
        GradleRunner runner = GradleRunner.create()
            .forwardOutput()
            .withPluginClasspath()
            .withArguments(gradleArguments)
            .withProjectDir(projectDir.getRoot());
        return runner;
    }
    private void prepareGradleFilesAndSources() throws IOException {
        // A dummy "source" file that is processed with bundlePlugin and put into a ZIP artifact file
        File bundleFile = new File(projectDir.getRoot(), PROJECT_NAME + "-source.txt");
        Files.createFile(bundleFile.toPath());
        // Setting a project name via settings.gradle file
        writeString(projectDir.newFile("settings.gradle"), "rootProject.name = '" + PROJECT_NAME + "'");
    }
    private void writeString(File file, String string) throws IOException {
        try (Writer writer = new FileWriter(file)) {
            writer.write(string);
        }
    }
    /**
     * Write the content of the "template" file into the target file.
     * The template file must be located in the {@value TEMPLATE_RESOURCE_FOLDER} folder.
     * @param targetFile A target file
     * @param templateFile A name of the template file located under {@value TEMPLATE_RESOURCE_FOLDER} folder
     */
    private void useTemplateFile(File targetFile, String templateFile) throws IOException, URISyntaxException {
        URL resource = getClass().getClassLoader().getResource(String.join(File.separator, TEMPLATE_RESOURCE_FOLDER, templateFile));
        Path resPath = Paths.get(resource.toURI()).toAbsolutePath();
        List