// Copyright 2015 The Bazel Authors. All rights reserved.
//
// Licensed 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 com.google.devtools.build.lib.shell;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.devtools.build.lib.shell.TestUtil.assertArrayEquals;
import static org.junit.Assert.fail;

import com.google.common.collect.ImmutableMap;
import com.google.devtools.build.lib.testutil.BlazeTestUtils;
import com.google.devtools.build.lib.testutil.TestConstants;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
 * Unit tests for {@link Command}. This test will only succeed on Linux, currently, because of its
 * non-portable nature.
 */
@RunWith(JUnit4.class)
public class CommandTest {

  // Platform-independent tests ----------------------------------------------

  @Before
  public final void configureLogger() throws Exception  {
    // Enable all log statements to ensure there are no problems with logging code.
    Logger.getLogger("com.google.devtools.build.lib.shell.Command").setLevel(Level.FINEST);
  }

  @Test
  public void testIllegalArgs() throws Exception {
    try {
      new Command((String[]) null);
      fail("Should have thrown NullPointerException");
    } catch (NullPointerException iae) {
      // good
    }

    try {
      new Command(new String[] {"/bin/true", null}).execute();
      fail("Should have thrown NullPointerException");
    } catch (NullPointerException npe) {
      // good
    }

    try {
      Command r = new Command(new String[] {"foo"});
      r.executeAsync((InputStream) null, Command.KILL_SUBPROCESS_ON_INTERRUPT).get();
      fail("Should have thrown NullPointerException");
    } catch (NullPointerException npe) {
      // good
    }

  }

  @Test
  public void testGetters() {
    File workingDir = new File(".");
    Map<String, String> env = Collections.singletonMap("foo", "bar");
    String[] commandArgs = new String[] { "command" };
    Command command = new Command(commandArgs, env, workingDir);
    assertArrayEquals(commandArgs, command.getCommandLineElements());
    for (String key : env.keySet()) {
      assertThat(command.getEnvironmentVariables()).containsEntry(key, env.get(key));
    }
    assertThat(command.getWorkingDirectory()).isEqualTo(workingDir);
  }

  // Platform-dependent tests ------------------------------------------------

  @Test
  public void testSimpleCommand() throws Exception {
    Command command = new Command(new String[] {"ls"});
    CommandResult result = command.execute();
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(result.getStderr()).isEmpty();
    assertThat(result.getStdout().length).isGreaterThan(0);
  }

  @Test
  public void testArguments() throws Exception {
    Command command = new Command(new String[] {"echo", "foo"});
    checkSuccess(command.execute(), "foo\n");
  }

  @Test
  public void testEnvironment() throws Exception {
    Map<String, String> env = Collections.singletonMap("FOO", "BAR");
    Command command = new Command(new String[] {"/bin/sh", "-c", "echo $FOO"}, env, null);
    checkSuccess(command.execute(), "BAR\n");
  }

  @Test
  public void testWorkingDir() throws Exception {
    Command command = new Command(new String[] {"pwd"}, null, new File("/"));
    checkSuccess(command.execute(), "/\n");
  }

  @Test
  public void testStdin() throws Exception {
    Command command = new Command(new String[] {"grep", "bar"});
    InputStream in = new ByteArrayInputStream("foobarbaz".getBytes());
    checkSuccess(
        command.executeAsync(in, Command.KILL_SUBPROCESS_ON_INTERRUPT).get(),
        "foobarbaz\n");
  }

  @Test
  public void testRawCommand() throws Exception {
    Command command = new Command(new String[] { "perl", "-e", "print 'a'x100000" });
    CommandResult result = command.execute();
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(result.getStderr()).isEmpty();
    assertThat(result.getStdout().length).isGreaterThan(0);
  }

  @Test
  public void testRawCommandWithDir() throws Exception {
    Command command = new Command(new String[] { "pwd" }, null, new File("/"));
    CommandResult result = command.execute();
    checkSuccess(result, "/\n");
  }

  @Test
  public void testHugeOutput() throws Exception {
    Command command = new Command(new String[] {"perl", "-e", "print 'a'x100000"});
    CommandResult result = command.execute();
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(result.getStderr()).isEmpty();
    assertThat(result.getStdout()).hasLength(100000);
  }

  @Test
  public void testNoStreamingInputForCat() throws Exception {
    Command command = new Command(new String[]{"/bin/cat"});
    ByteArrayInputStream emptyInput = new ByteArrayInputStream(new byte[0]);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    ByteArrayOutputStream err = new ByteArrayOutputStream();
    CommandResult result = command
        .executeAsync(emptyInput, out, err, Command.KILL_SUBPROCESS_ON_INTERRUPT)
        .get();
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(out.toString("UTF-8")).isEmpty();
    assertThat(err.toString("UTF-8")).isEmpty();
  }

  @Test
  public void testNoInputForCat() throws Exception {
    Command command = new Command(new String[]{"/bin/cat"});
    CommandResult result = command.execute();
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(new String(result.getStdout(), "UTF-8")).isEmpty();
    assertThat(new String(result.getStderr(), "UTF-8")).isEmpty();
  }

  @Test
  public void testProvidedOutputStreamCapturesHelloWorld() throws Exception {
    String helloWorld = "Hello, world.";
    Command command = new Command(new String[]{"/bin/echo", helloWorld});
    ByteArrayOutputStream stdOut = new ByteArrayOutputStream();
    ByteArrayOutputStream stdErr = new ByteArrayOutputStream();
    command.execute(stdOut, stdErr);
    assertThat(stdOut.toString("UTF-8")).isEqualTo(helloWorld + "\n");
    assertThat(stdErr.toByteArray()).isEmpty();
  }

  @Test
  public void testAsynchronous() throws Exception {
    File tempFile = File.createTempFile("googlecron-test", "tmp");
    tempFile.delete();
    Command command = new Command(new String[] {"touch", tempFile.getAbsolutePath()});
    FutureCommandResult result = command.executeAsync();
    result.get();
    assertThat(tempFile.exists()).isTrue();
    assertThat(result.isDone()).isTrue();
    tempFile.delete();
  }

  @Test
  public void testAsynchronousWithOutputStreams() throws Exception {
    String helloWorld = "Hello, world.";
    Command command = new Command(new String[]{"/bin/echo", helloWorld});
    ByteArrayInputStream emptyInput = new ByteArrayInputStream(new byte[0]);
    ByteArrayOutputStream stdOut = new ByteArrayOutputStream();
    ByteArrayOutputStream stdErr = new ByteArrayOutputStream();
    FutureCommandResult result =
        command.executeAsync(emptyInput, stdOut, stdErr, /* killSubprocessOnInterrupt= */ false);
    result.get(); // Make sure the process actually finished
    assertThat(stdOut.toString("UTF-8")).isEqualTo(helloWorld + "\n");
    assertThat(stdErr.toByteArray()).isEmpty();
  }

  @Test
  public void testTimeout() throws Exception {
    // Sleep for 3 seconds, but timeout after 1 second.
    Command command = new Command(new String[] {"sleep", "3"}, null, null, Duration.ofSeconds(1));
    try {
      command.execute();
      fail("Should have thrown AbnormalTerminationException");
    } catch (AbnormalTerminationException ate) {
      // good
      checkCommandElements(ate, "sleep", "3");
      checkATE(ate);
    }
  }

  @Test
  public void testTimeoutDoesntFire() throws Exception {
    Command command = new Command(new String[] {"cat"}, null, null, Duration.ofSeconds(2));
    InputStream in = new ByteArrayInputStream(new byte[]{'H', 'i', '!'});
    command.executeAsync(in, Command.KILL_SUBPROCESS_ON_INTERRUPT).get();
  }

  @Test
  public void testCommandDoesNotExist() throws Exception {
    Command command = new Command(new String[]{"thisisnotreal"});
    try {
      command.execute();
      fail();
    } catch (ExecFailedException e){
      // Good.
      checkCommandElements(e, "thisisnotreal");
    }
  }

  @Test
  public void testNoSuchCommand() throws Exception {
    final Command command = new Command(new String[] {"thisisnotreal"});
    try {
      command.execute();
      fail("Should have thrown ExecFailedException");
    } catch (ExecFailedException expected) {
      // good
    }
  }

  @Test
  public void testExitCodes() throws Exception {
    // 0 => success
    {
      String[] args = { "/bin/sh", "-c", "exit 0" };
      CommandResult result = new Command(args).execute();
      TerminationStatus status = result.getTerminationStatus();
      assertThat(status.success()).isTrue();
      assertThat(status.exited()).isTrue();
      assertThat(status.getExitCode()).isEqualTo(0);
    }

    // Every exit value in range [1-255] is reported as such (except [129-191],
    // which map to signals).
    for (int exit : new int[] { 1, 2, 3, 127, 128, 192, 255 }) {
      try {
        String[] args = { "/bin/sh", "-c", "exit " + exit };
        new Command(args).execute();
        fail("Should have exited with status " + exit);
      } catch (BadExitStatusException e) {
        assertThat(e).hasMessageThat().isEqualTo("Process exited with status " + exit);
        checkCommandElements(e, "/bin/sh", "-c", "exit " + exit);
        TerminationStatus status = e.getResult().getTerminationStatus();
        assertThat(status.success()).isFalse();
        assertThat(status.exited()).isTrue();
        assertThat(status.getExitCode()).isEqualTo(exit);
        assertThat(status.toShortString()).isEqualTo("Exit " + exit);
      }
    }

    // negative exit values are modulo 256:
    for (int exit : new int[] { -1, -2, -3 }) {
      int expected = 256 + exit;
      try {
        String[] args = { "/bin/bash", "-c", "exit " + exit };
        new Command(args).execute();
        fail("Should have exited with status " + expected);
      } catch (BadExitStatusException e) {
        assertThat(e).hasMessageThat().isEqualTo("Process exited with status " + expected);
        checkCommandElements(e, "/bin/bash", "-c", "exit " + exit);
        TerminationStatus status = e.getResult().getTerminationStatus();
        assertThat(status.success()).isFalse();
        assertThat(status.exited()).isTrue();
        assertThat(status.getExitCode()).isEqualTo(expected);
        assertThat(status.toShortString()).isEqualTo("Exit " + expected);
      }
    }
  }

  @Test
  public void testFailedWithSignal() throws Exception {
    // SIGHUP, SIGINT, SIGKILL, SIGTERM
    for (int signal : new int[] { 1, 2, 9, 15 }) {
      // Invoke a C++ program (killmyself.cc) that will die
      // with the specified signal.
      String killmyself = BlazeTestUtils.runfilesDir() + "/"
          + TestConstants.JAVATESTS_ROOT
          + "/com/google/devtools/build/lib/shell/killmyself";
      try {
        String[] args = { killmyself, "" + signal };
        new Command(args).execute();
        fail("Expected signal " + signal);
      } catch (AbnormalTerminationException e) {
        assertThat(e).hasMessageThat().isEqualTo("Process terminated by signal " + signal);
        checkCommandElements(e, killmyself, "" + signal);
        TerminationStatus status = e.getResult().getTerminationStatus();
        assertThat(status.success()).isFalse();
        assertThat(status.exited()).isFalse();
        assertThat(status.getTerminatingSignal()).isEqualTo(signal);

        switch (signal) {
          case 1: assertThat(status.toShortString()).isEqualTo("Hangup"); break;
          case 2: assertThat(status.toShortString()).isEqualTo("Interrupt"); break;
          case 9: assertThat(status.toShortString()).isEqualTo("Killed"); break;
          case 15: assertThat(status.toShortString()).isEqualTo("Terminated"); break;
          default: // fall out
        }
      }
    }
  }

  @Test
  public void testOnlyReadsPartialInput() throws Exception {
    // -c == --bytes, but -c also works on Darwin.
    Command command = new Command(new String[] {"head", "-c", "500"});
    OutputStream out = new ByteArrayOutputStream();
    InputStream in = new InputStream() {
      @Override
      public int read() {
        return 0; // write an unbounded amount
      }
    };

    CommandResult result =
        command.executeAsync(in, out, out, Command.KILL_SUBPROCESS_ON_INTERRUPT).get();
    TerminationStatus status = result.getTerminationStatus();
    assertThat(status.success()).isTrue();
  }

  @Test
  public void testFlushing() throws Exception {
    final Command command = new Command(
        // On darwin, /bin/sh does not support -n for the echo builtin.
        new String[] {"/bin/bash", "-c", "echo -n Foo; sleep 0.1; echo Bar"});
    // We run this command, passing in a special output stream that records when each flush()
    // occurs. We test that a flush occurs after writing "Foo" and that another flush occurs after
    // writing "Bar\n".
    boolean[] flushed = new boolean[8];
    OutputStream out = new OutputStream() {
      private int count = 0;
      @Override
      public void write(int b) throws IOException {
        count++;
      }
      @Override
      public void flush() throws IOException {
        flushed[count] = true;
      }
    };
    command.execute(out, System.err);
    assertThat(flushed[0]).isFalse();
    assertThat(flushed[1]).isFalse(); // 'F'
    assertThat(flushed[2]).isFalse(); // 'o'
    assertThat(flushed[3]).isTrue(); // 'o'   <- expect flush here.
    assertThat(flushed[4]).isFalse(); // 'B'
    assertThat(flushed[5]).isFalse(); // 'a'
    assertThat(flushed[6]).isFalse(); // 'r'
    assertThat(flushed[7]).isTrue(); // '\n'
  }

  @Test
  public void testOutputStreamThrowsException() throws Exception {
    OutputStream out = new OutputStream () {
      @Override
      public void write(int b) throws IOException {
        throw new IOException();
      }
    };
    Command command = new Command(new String[] {"/bin/echo", "foo"});
    try {
      command.execute(out, out);
      fail();
    } catch (AbnormalTerminationException e) {
      // Good.
      checkCommandElements(e, "/bin/echo", "foo");
      assertThat(e).hasMessageThat().isEqualTo("java.io.IOException");
    }
  }

  @Test
  public void testOutputStreamThrowsExceptionAndCommandFails() throws Exception {
    OutputStream out = new OutputStream () {
      @Override
      public void write(int b) throws IOException {
        throw new IOException();
      }
    };
    Command command = new Command(new String[] {"cat", "/dev/thisisnotreal"});
    try {
      command.execute(out, out);
      fail();
    } catch (AbnormalTerminationException e) {
      checkCommandElements(e, "cat", "/dev/thisisnotreal");
      TerminationStatus status = e.getResult().getTerminationStatus();
      // Subprocess either gets a SIGPIPE trying to write to our output stream,
      // or it exits with failure.  Both are observed, nondetermistically.
      assertThat(status.exited() ? status.getExitCode() == 1 : status.getTerminatingSignal() == 13)
          .isTrue();
      assertWithMessage(e.getMessage())
          .that(
              e.getMessage()
                  .endsWith("also encountered an error while attempting " + "to retrieve output"))
          .isTrue();
    }
  }

  private static void checkCommandElements(CommandException e,
      String... expected) {
    assertArrayEquals(expected, e.getCommand().getCommandLineElements());
  }

  private static void checkATE(final AbnormalTerminationException ate) {
    final CommandResult result = ate.getResult();
    assertThat(result.getTerminationStatus().success()).isFalse();
  }

  private static void checkSuccess(final CommandResult result,
                                   final String expectedOutput) {
    assertThat(result.getTerminationStatus().success()).isTrue();
    assertThat(result.getStderr()).isEmpty();
    assertThat(new String(result.getStdout())).isEqualTo(expectedOutput);
  }

  @Test
  public void testRelativePath() throws Exception {
    Command command = new Command(new String[]{"relative/path/to/binary"},
        ImmutableMap.<String, String>of(),
        new File("/working/directory"));
    assertThat(command.getCommandLineElements()[0])
        .isEqualTo("/working/directory/relative/path/to/binary");
  }
}
