BuildPlanExecutorTest.java

/*
 * 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.maven.lifecycle.internal.concurrent;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;

import org.apache.maven.api.Lifecycle;
import org.apache.maven.execution.DefaultMavenExecutionRequest;
import org.apache.maven.execution.DefaultMavenExecutionResult;
import org.apache.maven.execution.MavenExecutionRequest;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.execution.ProjectDependencyGraph;
import org.apache.maven.execution.ProjectExecutionEvent;
import org.apache.maven.execution.ProjectExecutionListener;
import org.apache.maven.internal.impl.DefaultLifecycleRegistry;
import org.apache.maven.internal.transformation.TransformerManager;
import org.apache.maven.lifecycle.LifecycleExecutionException;
import org.apache.maven.lifecycle.internal.LifecycleTask;
import org.apache.maven.lifecycle.internal.ReactorBuildStatus;
import org.apache.maven.lifecycle.internal.ReactorContext;
import org.apache.maven.lifecycle.internal.TaskSegment;
import org.apache.maven.lifecycle.internal.stub.ExecutionEventCatapultStub;
import org.apache.maven.project.MavenProject;
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.deployment.DeployRequest;
import org.eclipse.aether.installation.InstallRequest;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

class BuildPlanExecutorTest {

    /**
     * A build step that throws an {@link Error} must be reported as a build failure, the same way the
     * single threaded builder reports it. Otherwise the build ends with no exception at all and Maven
     * prints BUILD SUCCESS while nothing was built.
     */
    @Test
    void errorThrownByBuildStepIsRecordedAsBuildFailure() throws Exception {
        Error thrown = new NoClassDefFoundError("some/Class");
        MavenProject project = newProject();
        MavenSession session = newSession(project);

        execute(session, project, event -> {
            throw thrown;
        });

        List<Throwable> exceptions = session.getResult().getExceptions();
        assertEquals(1, exceptions.size(), "expected the error to be recorded, but got: " + exceptions);
        assertSame(thrown, exceptions.get(0));
        assertTrue(session.getResult().getBuildSummary(project) instanceof org.apache.maven.execution.BuildFailure);
    }

    /**
     * The same for an exception, which already worked. This pins the existing behaviour so the widened
     * catch does not change it.
     */
    @Test
    void exceptionThrownByBuildStepIsRecordedAsBuildFailure() throws Exception {
        RuntimeException thrown = new IllegalStateException("nope");
        MavenProject project = newProject();
        MavenSession session = newSession(project);

        execute(session, project, event -> {
            throw thrown;
        });

        List<Throwable> exceptions = session.getResult().getExceptions();
        assertEquals(1, exceptions.size(), "expected the exception to be recorded, but got: " + exceptions);
        assertSame(thrown, exceptions.get(0));
        assertTrue(session.getResult().getBuildSummary(project) instanceof org.apache.maven.execution.BuildFailure);
    }

    /**
     * A checked exception thrown by a build step is a soft failure: the reactor must not be halted, and
     * other projects must still be built when {@code REACTOR_FAIL_AT_END} is in effect. This pins the
     * soft-failure path of {@code isFatal} so a future change does not silently break {@code --fail-at-end}
     * for ordinary plugin failures.
     */
    @Test
    void checkedExceptionThrownByBuildStepDoesNotHaltReactor() throws Exception {
        MavenProject project = newProject();
        MavenSession session = newSession(project);
        session.getRequest().setReactorFailureBehavior(MavenExecutionRequest.REACTOR_FAIL_AT_END);

        ReactorContext reactorContext = execute(session, project, event -> {}, plan -> {
            BuildStep step = plan.step(project, "validate")
                    .orElseThrow(() -> new IllegalStateException("no validate step in the plan"));
            // LifecycleExecutionException is a checked exception ��� the soft-failure case for isFatal.
            step.exception = new LifecycleExecutionException("plugin failure");
            step.status.set(BuildStep.FAILED);
        });

        assertTrue(
                session.getResult().getBuildSummary(project) instanceof org.apache.maven.execution.BuildFailure,
                "the project must be recorded as a failure");
        assertTrue(
                !reactorContext.getReactorBuildStatus().isHalted(),
                "a checked exception must not halt the reactor when REACTOR_FAIL_AT_END is set");
    }

    /**
     * A project can end up with more than one failure: when a build step fails, the matching after:* step is
     * still run for cleanup and may fail on its own. Those failures are reported through a wrapper exception,
     * and the wrapper is a checked exception, so reading the severity off the wrapper hides the {@link Error}
     * that is inside it. A cleanup step failing after an Error must not downgrade the build from "halt" to a
     * plain per-project failure.
     */
    @Test
    void errorIsStillFatalWhenASecondFailureJoinsIt() throws Exception {
        Error thrown = new NoClassDefFoundError("some/Class");
        MavenProject project = newProject();
        MavenSession session = newSession(project);
        // with fail-fast the build halts whatever happens, so the downgrade is only observable with fail-at-end
        session.getRequest().setReactorFailureBehavior(MavenExecutionRequest.REACTOR_FAIL_AT_END);

        ReactorContext reactorContext = execute(
                session,
                project,
                event -> {
                    throw thrown;
                },
                plan -> {
                    BuildStep cleanup = plan.step(project, Lifecycle.AFTER + "validate")
                            .orElseThrow(() -> new IllegalStateException("no after:validate step in the plan"));
                    // stands in for the cleanup step failing after the Error, the way processStep records it
                    cleanup.exception = new LifecycleExecutionException("cleanup failed");
                });

        assertTrue(
                reactorContext.getReactorBuildStatus().isHalted(),
                "an Error must halt the reactor even when a second failure is recorded for the same project, but"
                        + " the build was not halted; recorded exceptions: "
                        + session.getResult().getExceptions());
    }

    private ReactorContext execute(MavenSession session, MavenProject project, BeforeProjectExecution listener)
            throws Exception {
        ReactorContext reactorContext = newReactorContext(session);
        newExecutor(listener).execute(session, reactorContext, List.of(newTaskSegment()));
        return reactorContext;
    }

    /**
     * Same, but the plan is handed to {@code planCustomizer} after it is created and before it is executed.
     * A step that runs no mojo cannot be made to fail from the outside, so this is the only way to put a
     * second failure on the project.
     */
    private ReactorContext execute(
            MavenSession session,
            MavenProject project,
            BeforeProjectExecution listener,
            Consumer<BuildPlan> planCustomizer)
            throws Exception {
        ReactorContext reactorContext = newReactorContext(session);
        try (BuildPlanExecutor.BuildContext context =
                newExecutor(listener).new BuildContext(session, reactorContext, List.of(newTaskSegment()))) {
            planCustomizer.accept(context.plan);
            context.execute();
        }
        return reactorContext;
    }

    private ReactorContext newReactorContext(MavenSession session) {
        return new ReactorContext(
                session.getResult(),
                Thread.currentThread().getContextClassLoader(),
                new ReactorBuildStatus(session.getProjectDependencyGraph()));
    }

    private TaskSegment newTaskSegment() {
        TaskSegment taskSegment = new TaskSegment(false);
        taskSegment.getTasks().add(new LifecycleTask("validate"));
        return taskSegment;
    }

    private BuildPlanExecutor newExecutor(ProjectExecutionListener listener) {
        return new BuildPlanExecutor(
                null,
                new ExecutionEventCatapultStub(),
                List.of(listener),
                new NoopTransformerManager(),
                new BuildPlanLogger(),
                Map.of(),
                null,
                null,
                new DefaultLifecycleRegistry(Collections.emptyList()));
    }

    private MavenProject newProject() {
        MavenProject result = new MavenProject();
        result.setArtifactId("a");
        result.setCollectedProjects(List.of());
        return result;
    }

    private MavenSession newSession(MavenProject project) {
        MavenExecutionRequest request = new DefaultMavenExecutionRequest();
        request.setGoals(List.of("validate"));
        MavenSession result = new MavenSession(
                null, new DefaultRepositorySystemSession(h -> false), request, new DefaultMavenExecutionResult());
        result.setProjectDependencyGraph(new SingleProjectDependencyGraph(project));
        result.setProjects(List.of(project));
        return result;
    }

    private static final class SingleProjectDependencyGraph implements ProjectDependencyGraph {

        private final List<MavenProject> projects;

        private SingleProjectDependencyGraph(MavenProject project) {
            this.projects = List.of(project);
        }

        @Override
        public List<MavenProject> getAllProjects() {
            return projects;
        }

        @Override
        public List<MavenProject> getSortedProjects() {
            return projects;
        }

        @Override
        public List<MavenProject> getDownstreamProjects(MavenProject project, boolean transitive) {
            return List.of();
        }

        @Override
        public List<MavenProject> getUpstreamProjects(MavenProject project, boolean transitive) {
            return List.of();
        }
    }

    private static final class NoopTransformerManager implements TransformerManager {

        @Override
        public InstallRequest remapInstallArtifacts(RepositorySystemSession session, InstallRequest request) {
            return request;
        }

        @Override
        public DeployRequest remapDeployArtifacts(RepositorySystemSession session, DeployRequest request) {
            return request;
        }

        @Override
        public void injectTransformedArtifacts(RepositorySystemSession repositorySession, MavenProject project) {}
    }

    @FunctionalInterface
    private interface BeforeProjectExecution extends ProjectExecutionListener {

        @Override
        void beforeProjectExecution(ProjectExecutionEvent event);

        @Override
        default void beforeProjectLifecycleExecution(ProjectExecutionEvent event) {}

        @Override
        default void afterProjectExecutionSuccess(ProjectExecutionEvent event) {}

        @Override
        default void afterProjectExecutionFailure(ProjectExecutionEvent event) {}
    }
}