#!/usr/bin/env php
<?php
/**
* Run the test suites in various configurations.
*/

function is_testing_dso_extension() {
  // detecting if we're running outside of the hhvm codebase
  return !is_file(__DIR__ . "/../../hphp/test/run");
}

function get_expect_file_and_type($test, $options) {
  $types = array('expect', 'expectf', 'expectregex');
  if (isset($options['repo'])) {
    foreach ($types as $type) {
      $fname = "$test.$type-repo";
      if (file_exists($fname)) {
        return array($fname, $type);
      }
    }
  }
  foreach ($types as $type) {
    $fname = "$test.$type";
    if (file_exists($fname)) {
      return array($fname, $type);
    }
  }
  return array(null, null);
}

function usage() {
  global $argv;
  return "usage: $argv[0] [-m jit|interp] [-r] <test/directories>";
}

function help() {
  global $argv;
  $ztestexample = 'test/zend/good/*/*z*.php'; // sep. for syntax highlighting
  $help = <<<EOT


This is the hhvm test-suite runner.  For more detailed documentation,
see hphp/test/README.md.

The test argument may be a path to a php test file, a directory name, or
one of a few pre-defined suite names that this script knows about.

If you work with hhvm a lot, you might consider a bash alias:

   alias ht="path/to/hphp/test/run"

Examples:

  # Quick tests in JIT mode:
  % $argv[0] test/quick

  # Slow tests in interp mode:
  % $argv[0] -m interp test/slow

  # PHP specificaion tests in JIT mode:
  % $argv[0] test/spec

  # Slow closure tests in JIT mode:
  % $argv[0] test/slow/closure

  # Slow closure tests in JIT mode with RepoAuthoritative:
  % $argv[0] -r test/slow/closure

  # Slow array tests, in RepoAuthoritative:
  % $argv[0] -r test/slow/array

  # Zend tests with a "z" in their name:
  % $argv[0] $ztestexample

  # Quick tests in JIT mode with some extra runtime options:
  % $argv[0] test/quick -a '-vEval.JitMaxTranslations=120 -vEval.HHIRRefcountOpts=0'

  # All quick tests except debugger
  % $argv[0] -e debugger test/quick

  # All tests except those containing a string of 3 digits
  % $argv[0] -E '/\d{3}/' all

  # All tests whose name containing pdo_mysql
  % $argv[0] -i pdo_mysql -m jit -r zend

  # Print all the standard tests
  % $argv[0] --list-tests

  # Use a specific HHVM binary
  % $argv[0] -b ~/code/hhvm/hphp/hhvm/hhvm
  % $argv[0] --hhvm-binary-path ~/code/hhvm/hphp/hhvm/hhvm

EOT;
  return usage().$help;
}

function error($message) {
  print "$message\n";
  exit(1);
}

// If a user-supplied path is provided, let's make sure we have a valid
// executable
function check_hhvm_executable($path) {
  $path = realpath($path);
  $msg = "Provided HHVM executable (" . $path . ") is not a file.\n"
       . "If using HHVM_BIN, make sure that is set correctly.";
  if (!is_file($path)) {
    error($msg);
  }
  $output = array();
  exec($path . " --version", $output);
  $str = implode($output);
  $msg = "Provided file (" . $path . ") is not an HHVM executable.\n"
       . "If using HHVM_BIN, make sure that is set correctly.";
  if (strpos($str, "HipHop") === false && strpos($str, "HHVM") === false) {
    error($msg);
  }
}

function check_for_multiple_default_hhvm_binaries() {
  $dir = hphp_home() . "/";
  if (getenv("FBMAKE_BIN_ROOT")) {
    $dir .= getenv("FBMAKE_BIN_ROOT");
  } else {
    $dir .= "_bin";
  }
  // For Facebook: We have two make systems fbmake and cmake, and we
  // can use both in the same code repo. If two binaries exist, we
  // want the onus to be on the user to specify a particular one because
  // before we chose the fbmake one by default and that can cause unexpected
  // results.
  if (file_exists($dir.'/hphp/hhvm/hhvm') &&
      file_exists(hphp_home().'/hphp/hhvm/hhvm')) {
    $msg = "Two binaries exist in this repo. \n"
         . " - " . $dir . "/hphp/hhvm/hhvm\n"
         . " - " . hphp_home() . "/hphp/hhvm/hhvm\n"
         . "Are you in fbcode? If so, remove a binary \n"
         . "or use the --hhvm-binary-path option to the test runner. \n"
         . "e.g., test/run -b hphp/hhvm/hhvm quick";
    error($msg);
  }
}

function hphp_home() {
  if (is_testing_dso_extension()) {
    return realpath(__DIR__);
  }
  return realpath(__DIR__.'/../..');
}

function idx($array, $key, $default = null) {
  return isset($array[$key]) ? $array[$key] : $default;
}

function hhvm_path() {
  $file = "";
  if (getenv("HHVM_BIN") !== false) {
    $file = realpath(getenv("HHVM_BIN"));
  } else {
    $file = bin_root().'/hhvm';
  }

  if (!is_file($file)) {
    if (is_testing_dso_extension()) {
      exec("which hhvm", $output);
      if (isset($output[0]) && $output[0]) {
        return $output[0];
      }
      error("You need to specify hhvm bin with env HHVM_BIN");
    }

    error("$file doesn't exist. Did you forget to build first?");
  }
  return rel_path($file);
}

function bin_root() {
  if (getenv("HHVM_BIN") !== false) {
    return dirname(realpath(getenv("HHVM_BIN")));
  }

  $dir = hphp_home() . "/";
  if (getenv("FBMAKE_BIN_ROOT") !== false) {
    $dir .= getenv("FBMAKE_BIN_ROOT") . '/hphp/hhvm';
  } else {
    $dir .= "_bin/hphp/hhvm";
  }
  return is_dir($dir) ?
    $dir :      # fbmake
    hphp_home() . "/hphp/hhvm" # github / cmake
  ;
}

function verify_hhbc() {
  if (getenv("VERIFY_HHBC") !== false) {
    return getenv($env_hhbc);
  }
  return bin_root().'/verify.hhbc';
}

function read_file($file) {
  return file_exists($file) ?
         str_replace('__DIR__', dirname($file),
           preg_replace('/\s+/', ' ', (file_get_contents($file))))
         : "";
}

// http://stackoverflow.com/questions/2637945/
function rel_path($to) {
  $from     = explode('/', getcwd().'/');
  $to       = explode('/', $to);
  $relPath  = $to;

  foreach ($from as $depth => $dir) {
    // find first non-matching dir
    if ($dir === $to[$depth]) {
      // ignore this directory
      array_shift($relPath);
    } else {
      // get number of remaining dirs to $from
      $remaining = count($from) - $depth;
      if ($remaining > 1) {
        // add traversals up to first matching dir
        $padLength = (count($relPath) + $remaining - 1) * -1;
        $relPath = array_pad($relPath, $padLength, '..');
        break;
      } else {
        $relPath[0] = './' . $relPath[0];
      }
    }
  }
  return implode('/', $relPath);
}

function get_options($argv) {
  $parameters = array(
    'exclude:' => 'e:',
    'exclude-pattern:' => 'E:',
    'include:' => 'i:',
    'include-pattern:' => 'I:',
    'repo' => 'r',
    'mode:' => 'm:',
    'server' => 's',
    'shuffle' => '',
    'help' => 'h',
    'verbose' => 'v',
    'fbmake' => '',
    'threads:' => '',
    'args:' => 'a:',
    'log' => 'l',
    'failure-file:' => '',
    'arm' => '',
    'llvm' => '',
    'force-llvm' => '',
    'bccf' => '',
    'hhas-round-trip' => '',
    'color' => 'c',
    'no-fun' => '',
    'cores' => '',
    'no-clean' => '',
    'list-tests' => '',
    'hhvm-binary-path:' => 'b:',
  );
  $options = array();
  $files = array();
  for ($i = 1; $i < count($argv); $i++) {
    $arg = $argv[$i];
    $found = false;
    if ($arg && $arg[0] == '-') {
      foreach ($parameters as $long => $short) {
        if ($arg == '-'.str_replace(':', '', $short) ||
            $arg == '--'.str_replace(':', '', $long)) {
          if (substr($long, -1, 1) == ':') {
            $value = $argv[++$i];
          } else {
            $value = true;
          }
          $options[str_replace(':', '', $long)] = $value;
          $found = true;
          break;
        }
      }
    }
    if (!$found && $arg) {
      $files[] = $arg;
    }
  }

  if (isset($options['repo']) && isset($options['hhas-round-trip'])) {
    echo "repo and hhas-round-trip are mutually exclusive options\n";
    exit(1);
  }

  return array($options, $files);
}

/*
 * We support some 'special' file names, that just know where the test
 * suites are, to avoid typing 'hphp/test/foo'.
 */
function map_convenience_filename($file) {
  $mappage = array(
    'quick'      => 'hphp/test/quick',
    'slow'       => 'hphp/test/slow',
    'spec'       => 'hphp/test/spec',
    'debugger'   => 'hphp/test/server/debugger/tests',
    'http'       => 'hphp/test/server/http/tests',
    'fastcgi'    => 'hphp/test/server/fastcgi/tests',
    'zend'       => 'hphp/test/zend/good',
    'facebook'   => 'hphp/facebook/test',

    // Subsets of zend tests.
    'zend_ext'   => 'hphp/test/zend/good/ext',
    'zend_Zend'  => 'hphp/test/zend/good/Zend',
    'zend_tests' => 'hphp/test/zend/good/tests',
    'zend_bad'   => 'hphp/test/zend/bad',
  );

  if (!isset($mappage[$file])) {
    return $file;
  }
  return hphp_home().'/'.$mappage[$file];
}

// Some tests have to be run together in the same test bucket, one after the
// other in order to avoid races and collisions
function serial_only_tests() {
  if (is_testing_dso_extension()) {
    return array();
  }
  // Can be a directory or a single test
  $serial = array (
    hphp_home().'/'.'hphp/test/slow/ext_memcached/memcached-getallkeys.php',
    hphp_home().'/'.'hphp/test/slow/ext_memcached/get_set.php',
    hphp_home().'/'.'hphp/test/slow/ext_memcache/get_compressed.php',
    hphp_home().'/'.'hphp/test/slow/ext_memcached/decompression.php',
    hphp_home().'/'.'hphp/test/slow/ext_memcached/types.php',
    hphp_home().'/'.'hphp/test/slow/ext_mysqli/store_result_updatemaxlength.php',
    hphp_home().'/'.'hphp/test/slow/ext_mysqli/test_mysqli.php',
    hphp_home().'/'.'hphp/test/slow/ext_mysqli/test_mysqli_bind.php',
  );
  $tests = find_tests($serial);
  return $tests;
}

function find_tests($files, array $options = null) {
  if (!$files) {
    $files = array('quick');
  }
  if ($files == array('all')) {
    $files = array('quick', 'slow', 'spec', 'zend', 'fastcgi');
  }
  foreach ($files as &$file) {
    $file = map_convenience_filename($file);
    if (!@stat($file)) {
      error("Not valid file or directory: '$file'");
    }
    $file = preg_replace(',//+,', '/', realpath($file));
    $file = preg_replace(',^'.getcwd().'/,', '', $file);
  }
  $files = array_map('escapeshellarg', $files);
  $files = implode(' ', $files);
  $tests = explode("\n", shell_exec(
    "find $files -name '*.php' -o -name '*.hhas' | grep -v round_trip.hhas"
  ));
  if (!$tests) {
    error(usage());
  }
  asort($tests);
  $tests = array_filter($tests);
  if (!empty($options['exclude'])) {
    $exclude = $options['exclude'];
    $tests = array_filter($tests, function($test) use ($exclude) {
      return (false === strpos($test, $exclude));
    });
  }
  if (!empty($options['exclude-pattern'])) {
    $exclude = $options['exclude-pattern'];
    $tests = array_filter($tests, function($test) use ($exclude) {
      return !preg_match($exclude, $test);
    });
  }
  if (!empty($options['include'])) {
    $include = $options['include'];
    $tests = array_filter($tests, function($test) use ($include) {
      return (false !== strpos($test, $include));
    });
  }
  if (!empty($options['include-pattern'])) {
    $include = $options['include-pattern'];
    $tests = array_filter($tests, function($test) use ($include) {
      return preg_match($include, $test);
    });
  }
  return $tests;
}

function list_tests() {
  foreach (find_tests(array('all')) as $test) {
    foreach (array('-m interp', '-m jit') as $mode) {
      foreach (array('', '-r') as $repo) {
        print implode(' ', array_filter(array($mode, $repo, $test)))."\n";
      }
    }
  }
}

function find_test_ext($test, $ext) {
  if (is_file("{$test}.{$ext}")) {
    return "{$test}.{$ext}";
  }
  return find_file_for_dir(dirname($test), "config.{$ext}");
}

function find_file($test, $name) {
  return find_file_for_dir(dirname($test), $name);
}

function find_file_for_dir($dir, $name) {
  // Handle the case where the $dir might come in as '.' because you
  // are running the test runner on a file from the same directory as
  // the test e.g., './mytest.php'. dirname() will give you the '.' when
  // you actually have a lot of path to traverse upwards like
  // /home/you/code/tests/mytest.php. Use realpath() to get that.
  $dir = realpath($dir);
  while ($dir !== '/' && is_dir($dir)) {
    $file = "$dir/$name";
    if (is_file($file)) {
      return $file;
    }
    $dir = dirname($dir);
  }
  $file = __DIR__.'/'.$name;
  if (file_exists($file)) {
    return $file;
  }
  return null;
}

function find_debug_config($test, $name) {
  $debug_config = find_file_for_dir(dirname($test), $name);
  if ($debug_config !== null) {
    return "-m debug --debug-config ".$debug_config;
  }
  return "";
}

function mode_cmd($options) {
  $repo_args = '';
  if (!isset($options['repo'])) {
    // Set the non-repo-mode shared repo.
    // When in repo mode, we set our own central path.
    $repo_args = "-vRepo.Local.Mode=-- -vRepo.Central.Path=".verify_hhbc();
  }
  $jit_args = "$repo_args -vEval.Jit=true";
  $mode = idx($options, 'mode', '');
  switch ($mode) {
    case '':
    case 'jit':
      return "$jit_args";
    case 'llvm':
      return "$jit_args -vEval.JitLLVM=2";
    case 'pgo':
      return $jit_args.
        ' -vEval.JitPGO=1'.
        ' -vEval.JitPGORegionSelector=hottrace'.
        ' -vEval.JitPGOHotOnly=0';
    case 'interp':
      return "$repo_args -vEval.Jit=0";
    default:
      error("-m must be one of jit | pgo | interp. Got: '$mode'");
  }
}

function extra_args($options) {
  return idx($options, 'args', '');
}

function hhvm_cmd_impl() {
  $args = func_get_args();
  $options = array_shift($args);
  $config = array_shift($args);
  $extra_args = $args;
  $args = array(
    hhvm_path(),
    '-c',
    $config,
    '-vEval.EnableArgsInBacktraces=true',
    '-vEval.EnableIntrinsicsExtension=true',
    mode_cmd($options),
    isset($options['arm']) ? '-vEval.SimulateARM=1' : '',
    isset($options['llvm']) ? '-vEval.JitLLVM=2' : '',
    isset($options['require-llvm']) ? '-vEval.JitLLVM=3' : '',
    isset($options['bccf']) ? '-vEval.HHIRBytecodeControlFlow=1' : '',
    extra_args($options),
  );
  if (!isset($options['cores'])) {
    $args[] = '-vResourceLimit.CoreFileSize=0';
  }

  return implode(' ', array_merge($args, $extra_args));
}

// Return the command and the env to run it in.
function hhvm_cmd($options, $test, $test_run = null) {
  if ($test_run === null) {
    $test_run = $test;
  }
  // hdf support is only temporary until we fully migrate to ini
  // Discourage broad use.
  $hdf_suffix = ".use.for.ini.migration.testing.only.hdf";
  $hdf = file_exists($test.$hdf_suffix)
       ? '-c ' . $test . $hdf_suffix
       : "";
  $cmd = hhvm_cmd_impl(
    $options,
    find_test_ext($test, 'ini'),
    $hdf,
    find_debug_config($test, 'hphpd.ini'),
    read_file(find_test_ext($test, 'opts')),
    '--file',
    escapeshellarg($test_run)
  );

  if (file_exists($test.'.ini')) {
    file_put_contents($test_ini = tempnam('/tmp', $test).'.ini',
                      str_replace('{PWD}', dirname($test),
                                  file_get_contents($test.'.ini')));
    $cmd .= " -c $test_ini";
  }
  if ($hdf !== "") {
    file_put_contents($test_hdf = tempnam('/tmp', $test).$hdf_suffix,
                      str_replace('{PWD}', dirname($test),
                                  file_get_contents($test.$hdf_suffix)));
    $cmd .= " -c $test_hdf";
  }

  if (isset($options['repo'])) {
    $hhbbc_repo = "\"$test.repo/hhvm.hhbbc\"";
    $cmd .= ' -vRepo.Authoritative=true -vRepo.Commit=0';
    $cmd .= " -vRepo.Central.Path=$hhbbc_repo";
  }

  $env = $_ENV;
  $in = find_test_ext($test, 'in');
  if ($in !== null) {
    $cmd .= ' < ' . escapeshellarg($in);
    // If we're piping the input into the command then setup a simple
    // dumb terminal so hhvm doesn't try to control it and pollute the
    // output with control characters, which could change depending on
    // a wide variety of terminal settings.
    $env["TERM"] = "dumb";
  }

  return array($cmd, $env);
}

function hphp_cmd($options, $test) {
  return implode(" ", array(
    "HHVM_DISABLE_HHBBC2=1",
    hhvm_path(),
    '--hphp',
    '--config',
    find_file($test, 'hphp_config.ini'),
    read_file("$test.hphp_opts"),
    "-thhbc -l0 -k1 -o \"$test.repo\" \"$test\"",
  ));
}

function hhbbc_cmd($options, $test) {
  return implode(" ", array(
    hhvm_path(),
    '--hhbbc',
    '--no-logging',
    '--parallel-num-threads=1',
    read_file("$test.hhbbc_opts"),
    "-o \"$test.repo/hhvm.hhbbc\" \"$test.repo/hhvm.hhbc\"",
  ));
}

class Status {
  private static $results = array();
  private static $mode = 0;

  private static $use_color = false;

  private static $queue = null;
  private static $killed = false;
  public static $key;

  const MODE_NORMAL = 0;
  const MODE_VERBOSE = 1;
  const MODE_FBMAKE = 2;

  const MSG_FINISHED = 1;
  const MSG_TEST_PASS = 2;
  const MSG_TEST_FAIL = 4;
  const MSG_TEST_SKIP = 5;
  const MSG_SERVER_RESTARTED = 6;

  const RED = 31;
  const GREEN = 32;
  const YELLOW = 33;

  const PASS_SERVER = 0;
  const SKIP_SERVER = 1;
  const PASS_CLI = 2;

  public static function setMode($mode) {
    self::$mode = $mode;
  }

  public static function setUseColor($use) {
    self::$use_color = $use;
  }

  public static function getMode() {
    return self::$mode;
  }

  public static function finished() {
    self::send(self::MSG_FINISHED, null);
  }

  public static function killQueue() {
    if (!self::$killed) {
      msg_remove_queue(self::$queue);
      self::$queue = null;
      self::$killed = true;
    }
  }

  public static function pass($test, $detail, $time, $stime, $etime) {
    array_push(self::$results, array('name' => $test,
                                     'status' => 'passed',
                                     'start_time' => $stime,
                                     'end_time' => $etime,
                                     'time' => $time));
    $how = $detail === 'pass-server' ? self::PASS_SERVER :
      ($detail === 'skip-server' ? self::SKIP_SERVER : self::PASS_CLI);
    self::send(self::MSG_TEST_PASS, array($test, $how, $time, $stime, $etime));
  }

  public static function skip($test, $reason, $time, $stime, $etime) {
    if (self::getMode() !== self::MODE_FBMAKE) {
      array_push(self::$results, array('name' => $test,
                                       'status' => 'skipped',
                                       'start_time' => $stime,
                                       'end_time' => $etime,
                                       'time' => $time));
    }
    self::send(self::MSG_TEST_SKIP, array($test, $reason, $time, $stime, $etime));
  }

  public static function fail($test, $time, $stime, $etime) {
    array_push(self::$results, array(
      'name' => $test,
      'status' => 'failed',
      'details' => self::utf8Sanitize(@file_get_contents("$test.diff")),
      'start_time' => $stime,
      'end_time' => $etime,
      'time' => $time
    ));
    self::send(self::MSG_TEST_FAIL, array($test, $time, $stime, $etime));
  }

  public static function serverRestarted() {
    self::send(self::MSG_SERVER_RESTARTED, null);
  }

  private static function send($type, $msg) {
    if (self::$killed) {
      return;
    }
    msg_send(self::getQueue(), $type, $msg);
  }

  /**
   * Takes a variable number of string arguments. If color output is enabled
   * and any one of the arguments is preceded by an integer (see the color
   * constants above), that argument will be given the indicated color.
   */
  public static function sayColor() {
    $args = func_get_args();
    while (count($args)) {
      $color = null;
      $str = array_shift($args);
      if (is_integer($str)) {
        $color = $str;
        if (self::$use_color) {
          print "\033[0;${color}m";
        }
        $str = array_shift($args);
      }

      print $str;

      if (self::$use_color && !is_null($color)) {
        print "\033[0m";
      }
    }
  }

  public static function sayFBMake($test, $status, $stime, $etime) {
    $start = array('op' => 'start', 'test' => $test);
    $end = array('op' => 'test_done', 'test' => $test, 'status' => $status,
                 'start_time' => $stime, 'end_time' => $etime);
    if ($status == 'failed') {
      $end['details'] = self::utf8Sanitize(@file_get_contents("$test.diff"));
    }
    self::say($start, $end);
  }

  public static function getResults() {
    return self::$results;
  }

  /** Output is in the format expected by JsonTestRunner. */
  public static function say(/* ... */) {
    $data = array_map(function($row) {
      return self::jsonEncode($row) . "\n";
    }, func_get_args());
    fwrite(STDERR, implode("", $data));
  }

  public static function hasCursorControl() {
    // for runs on hudson-ci.org (aka jenkins)
    if (getenv("HUDSON_URL")) {
      return false;
    }
    // for runs on travis-ci.org
    if (getenv("TRAVIS")) {
      return false;
    }
    $stty = self::getSTTY();
    if (!$stty) {
      return false;
    }
    return strpos($stty, 'erase = <undef>') === false;
  }

  public static function getSTTY() {
    $descriptorspec = array(1 => array("pipe", "w"), 2 => array("pipe", "w"));
    $process = proc_open(
      'stty -a', $descriptorspec, $pipes, null, null,
      array('suppress_errors' => true)
    );
    $stty = stream_get_contents($pipes[1]);
    proc_close($process);
    return $stty;
  }

  public static function utf8Sanitize($str) {
    if (!is_string($str)) {
      // We sometimes get called with the
      // return value of file_get_contents()
      // when fgc() has failed.
      return '';
    }

    if (class_exists('UConverter')) {
      return UConverter::transcode($str, 'UTF-8', 'UTF-8');
    }

    // UConverter is PHP5.5 or later
    // Do the equivalent using a slower user-space implementation
    $ret = '';
    $len = strlen($str);
    $pos = 0;
    while ($pos < $len) {
      $c = $str[$pos];
      $co = ord($c);
      if ($co < 0x80) {
        // U+0000 - U+007F ASCII
        $ret .= $c;
        ++$pos;
      } elseif ((($co & 0xE0) == 0xC0) &&
                (($len - $pos) > 1) &&
                ((ord($str[$pos+1]) & 0xC0) == 0x80)) {
        // U+0080 - U+07FF Lower Basic Multilingual Plane
        $ret .= substr($str, $pos, 2);
        $pos += 2;
      } elseif ((($co & 0xF0) == 0xE0) &&
                (($len - $pos) > 2) &&
                ((ord($str[$pos+1]) & 0xC0) == 0x80) &&
                ((ord($str[$pos+2]) & 0xC0) == 0x80)) {
        // U+0800 - U+FFFF Upper Basic Multilingual Plane
        $ret .= substr($str, $pos, 3);
        $pos += 3;
      } elseif ((($co & 0xF8) == 0xF0) &&
                (($len - $pos) > 3) &&
                ((ord($str[$pos+1]) & 0xC0) == 0x80) &&
                ((ord($str[$pos+2]) & 0xC0) == 0x80) &&
                ((ord($str[$pos+3]) & 0xC0) == 0x80)) {
        // U+010000 - U+10FFFF Supplementary Multilingual Planes
        $ret .= substr($str, $pos, 4);
        $pos += 4;
      } else {
        // Invalid UTF8
        $ret .= "\xEF\xBF\xBD"; // U+FFFD REPLACEMENT CHARACTER
        ++$pos;
      }
    }
    return $ret;
  }

  public static function jsonEncode($data) {
    // JSON_UNESCAPED_SLASHES is Zend 5.4+
    if (defined("JSON_UNESCAPED_SLASHES")) {
      return json_encode($data, JSON_UNESCAPED_SLASHES);
    }

    $json = json_encode($data);
    return str_replace('\\/', '/', $json);
  }

  public static function getQueue() {
    if (!self::$queue) {
      self::$queue = msg_get_queue(self::$key);
    }
    return self::$queue;
  }
}

function clean_intermediate_files($test, $options) {
  if (isset($options['no-clean'])) {
    return;
  }
  $exts = array('out', 'diff', 'repo');
  foreach ($exts as $ext) {
    $file = "$test.$ext";
    if (file_exists($file)) {
      if (is_dir($file)) {
        foreach(new RecursiveIteratorIterator(new
            RecursiveDirectoryIterator($file, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST) as $path) {
          $path->isDir() ? rmdir($path->getPathname()) : unlink($path->getPathname());
        }
        rmdir($file);
      } else {
        unlink($file);
      }
    }
  }
}

function run($options, $tests, $bad_test_file) {
  foreach ($tests as $test) {
    $stime = time();
    $time = microtime(true);
    $status = run_and_lock_test($options, $test);
    $time = microtime(true) - $time;
    $etime = time();
    if ($status === 'skip') {
      Status::skip($test, null, $time, $stime, $etime);
      clean_intermediate_files($test, $options);
    } else if ($status === 'skip-norepo') {
      Status::skip($test, 'norepo', $time, $stime, $etime);
      clean_intermediate_files($test, $options);
    } else if ($status === 'skip-onlyrepo') {
      Status::skip($test, 'onlyrepo', $time, $stime, $etime);
      clean_intermediate_files($test, $options);
    } else if ($status) {
      Status::pass($test, $status, $time, $stime, $etime);
      clean_intermediate_files($test, $options);
    } else {
      Status::fail($test, $time, $stime, $etime);
    }
  }
  file_put_contents($bad_test_file, json_encode(Status::getResults()));
  foreach (Status::getResults() as $result) {
    if ($result['status'] == 'failed') {
      return 1;
    }
  }
  return 0;
}

function skip_test($options, $test) {
  $skipif_test = find_test_ext($test, 'skipif');
  if (!$skipif_test) {
    return false;
  }

  // For now, run the .skipif in non-repo since building a repo for it is hard
  $options_without_repo = $options;
  unset($options_without_repo['repo']);

  list($hhvm, $_) = hhvm_cmd($options_without_repo, $test, $skipif_test);
  $descriptorspec = array(
    0 => array("pipe", "r"),
    1 => array("pipe", "w"),
    2 => array("pipe", "w"),
  );
  $pipes = null;
  $process = proc_open("$hhvm $test 2>&1", $descriptorspec, $pipes);
  if (!is_resource($process)) {
    // This is weird. We can't run HHVM but we probably shouldn't skip the test
    // since on a broken build everything will show up as skipped and give you a
    // SHIPIT
    return false;
  }

  fclose($pipes[0]);
  $output = stream_get_contents($pipes[1]);
  fclose($pipes[1]);
  proc_close($process);

  // The standard php5 .skipif semantics is if the .skipif outputs ANYTHING
  // then it should be skipped. This is a poor design, but I'll just add a
  // small blacklist of things that are really bad if they are output so we
  // surface the errors in the tests themselves.
  if (stripos($output, 'segmentation fault') !== false) {
    return false;
  }

  return strlen($output) != 0;
}

function comp_line($l1, $l2, $is_reg) {
  if ($is_reg) {
    return preg_match('/^'. $l1 . '$/s', $l2);
  } else {
    return !strcmp($l1, $l2);
  }
}

function count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $idx2, $cnt1, $cnt2, $steps) {
  $equal = 0;

  while ($idx1 < $cnt1 && $idx2 < $cnt2 && comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) {
    $idx1++;
    $idx2++;
    $equal++;
    $steps--;
  }
  if (--$steps > 0) {
    $eq1 = 0;
    $st = $steps / 2;

    for ($ofs1 = $idx1 + 1; $ofs1 < $cnt1 && $st-- > 0; $ofs1++) {
      $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $ofs1, $idx2, $cnt1, $cnt2, $st);

      if ($eq > $eq1) {
        $eq1 = $eq;
      }
    }

    $eq2 = 0;
    $st = $steps;

    for ($ofs2 = $idx2 + 1; $ofs2 < $cnt2 && $st-- > 0; $ofs2++) {
      $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $ofs2, $cnt1, $cnt2, $st);
      if ($eq > $eq2) {
        $eq2 = $eq;
      }
    }

    if ($eq1 > $eq2) {
      $equal += $eq1;
    } else if ($eq2 > 0) {
      $equal += $eq2;
    }
  }

  return $equal;
}

function generate_array_diff($ar1, $ar2, $is_reg, $w) {
  $idx1 = 0; $ofs1 = 0; $cnt1 = @count($ar1);
  $idx2 = 0; $ofs2 = 0; $cnt2 = @count($ar2);
  $diff = array();
  $old1 = array();
  $old2 = array();

  while ($idx1 < $cnt1 && $idx2 < $cnt2) {

    if (comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) {
      $idx1++;
      $idx2++;
      continue;
    } else {

      $c1 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1+1, $idx2, $cnt1, $cnt2, 10);
      $c2 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $idx2+1, $cnt1, $cnt2, 10);

      if ($c1 > $c2) {
        $old1[$idx1] = sprintf("%03d- ", $idx1+1) . $w[$idx1++];
        $last = 1;
      } else if ($c2 > 0) {
        $old2[$idx2] = sprintf("%03d+ ", $idx2+1) . $ar2[$idx2++];
        $last = 2;
      } else {
        $old1[$idx1] = sprintf("%03d- ", $idx1+1) . $w[$idx1++];
        $old2[$idx2] = sprintf("%03d+ ", $idx2+1) . $ar2[$idx2++];
      }
    }
  }

  reset($old1); $k1 = key($old1); $l1 = -2;
  reset($old2); $k2 = key($old2); $l2 = -2;

  while ($k1 !== null || $k2 !== null) {

    if ($k1 == $l1 + 1 || $k2 === null) {
      $l1 = $k1;
      $diff[] = current($old1);
      $k1 = next($old1) ? key($old1) : null;
    } else if ($k2 == $l2 + 1 || $k1 === null) {
      $l2 = $k2;
      $diff[] = current($old2);
      $k2 = next($old2) ? key($old2) : null;
    } else if ($k1 < $k2) {
      $l1 = $k1;
      $diff[] = current($old1);
      $k1 = next($old1) ? key($old1) : null;
    } else {
      $l2 = $k2;
      $diff[] = current($old2);
      $k2 = next($old2) ? key($old2) : null;
    }
  }

  while ($idx1 < $cnt1) {
    $diff[] = sprintf("%03d- ", $idx1 + 1) . $w[$idx1++];
  }

  while ($idx2 < $cnt2) {
    $diff[] = sprintf("%03d+ ", $idx2 + 1) . $ar2[$idx2++];
  }

  return $diff;
}

function generate_diff($wanted, $wanted_re, $output)
{
  $w = explode("\n", $wanted);
  $o = explode("\n", $output);
  $r = is_null($wanted_re) ? $w : explode("\n", $wanted_re);
  $diff = generate_array_diff($r, $o, !is_null($wanted_re), $w);

  return implode("\r\n", $diff);
}

function dump_hhas_to_temp($hhvm_cmd, $test) {
  $tmp_file = $test . '.round_trip.hhas';
  system("$hhvm_cmd -vEval.DumpHhas=1 > $tmp_file", $ret);
  if ($ret) { echo "system failed\n"; exit(1); }
  return $tmp_file;
}

const HHAS_EXT = '.hhas';
function can_run_server_test($test) {
  return
    !is_file("$test.noserver") &&
    !find_test_ext($test, 'opts') &&
    !is_file("$test.ini") &&
    !is_file("$test.onlyrepo") &&
    strpos($test, 'quick/debugger') === false &&
    strpos($test, 'quick/xenon') === false &&
    strpos($test, 'slow/streams/') === false &&
    strpos($test, 'slow/ext_mongo/') === false &&
    strpos($test, 'slow/ext_oauth/') === false &&
    strpos($test, 'slow/ext_yaml/') === false &&
    strpos($test, 'slow/debugger/') === false &&
    strpos($test, 'slow/type_profiler/debugger/') === false &&
    strpos($test, 'zend/good/ext/standard/tests/array/') === false &&
    strpos($test, 'zend/good/ext/ftp') === false &&
    strrpos($test, HHAS_EXT) !== (strlen($test) - strlen(HHAS_EXT))
    ;
}

const SERVER_TIMEOUT = 45;
function run_config_server($options, $test) {
  if (!isset($options['server']) || !can_run_server_test($test)) {
    return null;
  }

  $config = find_file_for_dir(dirname($test), 'config.ini');
  $port = $options['servers']['configs'][$config]['port'];
  $ch = curl_init("localhost:$port/$test");
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_TIMEOUT, SERVER_TIMEOUT);
  curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
  $output = curl_exec($ch);
  if ($output === false) {
    // The server probably crashed so fall back to cli to determine if this was
    // the test that caused the crash. Our parent process will see that the
    // server died and restart it.
    if (getenv('HHVM_TEST_SERVER_LOG')) {
      printf("Curl failed: %d\n", curl_errno($ch));
    }
    return null;
  }
  curl_close($ch);
  $output = trim($output);

  return array($output, '');
}

function run_config_cli($options, $test, $hhvm, $hhvm_env) {
  if (isset($options['log'])) {
    $hhvm_env['TRACE'] = 'printir:2';
    $hhvm_env['HPHP_TRACE_FILE'] = $test . '.log';
  }

  $descriptorspec = array(
    0 => array("pipe", "r"),
    1 => array("pipe", "w"),
    2 => array("pipe", "w"),
  );
  $pipes = null;
  $process = proc_open("$hhvm 2>&1", $descriptorspec, $pipes, null, $hhvm_env);
  if (!is_resource($process)) {
    file_put_contents("$test.diff", "Couldn't invoke $hhvm");
    return false;
  }

  fclose($pipes[0]);
  $output = trim(stream_get_contents($pipes[1]));
  $stderr = stream_get_contents($pipes[2]);
  fclose($pipes[1]);
  fclose($pipes[2]);
  proc_close($process);

  return array($output, $stderr);
}

function run_config_post($outputs, $test, $options) {
  $output = $outputs[0];
  $stderr = $outputs[1];
  file_put_contents("$test.out", $output);

  // hhvm redirects errors to stdout, so anything on stderr is really bad
  if ($stderr) {
    file_put_contents(
      "$test.diff",
      "Test failed because the process wrote on stderr:\n$stderr"
    );
    return false;
  }

  // Needed for testing non-hhvm binaries that don't actually run the code
  // e.g. parser/test/parse_tester.cpp
  if ($output == "FORCE PASS") {
    return true;
  }

  list($file, $type) = get_expect_file_and_type($test, $options);
  if ($file === null || $type === null) {
    file_put_contents("$test.diff", "No $test.expect, $test.expectf, ".
      "nor $test.expectregex. Is $test even a test?");
    return false;
  }

  if ($type === 'expect') {
    $wanted = trim(file_get_contents($file));
    $passed = !strcmp($output, $wanted);

    if (!$passed) {
      file_put_contents("$test.diff", generate_diff($wanted, null, $output));
    }

    return $passed;
  } else if ($type === 'expectf') {
    $wanted = trim(file_get_contents($file));
    $wanted_re = $wanted;

    // do preg_quote, but miss out any %r delimited sections
    $temp = "";
    $r = "%r";
    $startOffset = 0;
    $length = strlen($wanted_re);
    while($startOffset < $length) {
      $start = strpos($wanted_re, $r, $startOffset);
      if ($start !== false) {
        // we have found a start tag
        $end = strpos($wanted_re, $r, $start+2);
        if ($end === false) {
          // unbalanced tag, ignore it.
          $end = $start = $length;
        }
      } else {
        // no more %r sections
        $start = $end = $length;
      }
      // quote a non re portion of the string
      $temp = $temp.preg_quote(substr($wanted_re, $startOffset,
                                      ($start - $startOffset)),  '/');
      // add the re unquoted.
      if ($end > $start) {
        $temp = $temp.'('.substr($wanted_re, $start+2, ($end - $start-2)).')';
      }
      $startOffset = $end + 2;
    }
    $wanted_re = $temp;

    $wanted_re = str_replace(
      array('%binary_string_optional%'),
      'string',
      $wanted_re
    );
    $wanted_re = str_replace(
      array('%unicode_string_optional%'),
      'string',
      $wanted_re
    );
    $wanted_re = str_replace(
      array('%unicode\|string%', '%string\|unicode%'),
      'string',
      $wanted_re
    );
    $wanted_re = str_replace(
      array('%u\|b%', '%b\|u%'),
      '',
      $wanted_re
    );
    // Stick to basics
    $wanted_re = str_replace('%e', '\\' . DIRECTORY_SEPARATOR, $wanted_re);
    $wanted_re = str_replace('%s', '[^\r\n]+', $wanted_re);
    $wanted_re = str_replace('%S', '[^\r\n]*', $wanted_re);
    $wanted_re = str_replace('%a', '.+', $wanted_re);
    $wanted_re = str_replace('%A', '.*', $wanted_re);
    $wanted_re = str_replace('%w', '\s*', $wanted_re);
    $wanted_re = str_replace('%i', '[+-]?\d+', $wanted_re);
    $wanted_re = str_replace('%d', '\d+', $wanted_re);
    $wanted_re = str_replace('%x', '[0-9a-fA-F]+', $wanted_re);
    // %f allows two points "-.0.0" but that is the best *simple* expression
    $wanted_re = str_replace('%f', '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?',
                             $wanted_re);
    $wanted_re = str_replace('%c', '.', $wanted_re);
    // must be last
    $wanted_re = str_replace('%%', '%%?', $wanted_re);

    // Normalize newlines
    $wanted_re = preg_replace("/(\r\n?|\n)/", "\n", $wanted_re);
    $output    = preg_replace("/(\r\n?|\n)/", "\n", $output);

    $passed = preg_match("/^$wanted_re\$/s", $output);

    if (!$passed) {
      file_put_contents("$test.diff", generate_diff($wanted, $wanted_re, $output));
    }

    return $passed;

  } else if ($type === 'expectregex') {
    $wanted_re = trim(file_get_contents($file));

    $passed = preg_match("/^$wanted_re\$/s", $output);

    if (!$passed) {
      file_put_contents("$test.diff", generate_diff($wanted_re, $wanted_re, $output));
    }

    return $passed;
  } else {
    throw new Exception("Unsupported expect file type: ".$type);
  }
}

function run_one_config($options, $test, $hhvm, $hhvm_env) {
  $outputs = run_config_cli($options, $test, $hhvm, $hhvm_env);
  if ($outputs === false) return false;
  return run_config_post($outputs, $test, $options);
}

function run_and_lock_test($options, $test) {
  $lock = fopen($test, 'r');
  if (!flock($lock, LOCK_EX)) return false;
  $result = run_test($options, $test);
  if (!flock($lock, LOCK_UN)) return false;
  fclose($lock);
  return $result;
}

function run_test($options, $test) {
  if (skip_test($options, $test)) return 'skip';

  $test_ext = pathinfo($test, PATHINFO_EXTENSION);
  list($hhvm, $hhvm_env) = hhvm_cmd($options, $test);

  if (is_executable('/usr/bin/timeout')) {
    $hhvm = '/usr/bin/timeout 300 '.$hhvm;
  } else {
    $hhvm = __DIR__.'/../tools/timeout.sh -t 300 '.$hhvm;
  }

  if (isset($options['repo'])) {
    if ($test_ext === 'hhas' ||
        strpos($hhvm, '-m debug') !== false ||
        file_exists($test.'.norepo')) {
      return 'skip-norepo';
    }

    $hphp_repo = "$test.repo/hhvm.hhbc";
    $hhbbc_repo = "$test.repo/hhvm.hhbbc";
    shell_exec("rm -f \"$hphp_repo\" \"$hhbbc_repo\" ");
    $hphp = hphp_cmd($options, $test);
    $hhbbc = hhbbc_cmd($options, $test);
    shell_exec("$hphp 2>&1");
    shell_exec("$hhbbc 2>&1");

    return run_one_config($options, $test, $hhvm, $hhvm_env);
  }

  if (file_exists($test.'.onlyrepo')) {
    return 'skip-onlyrepo';
  }
  if (isset($options['hhas-round-trip'])) {
    $hhas_temp = dump_hhas_to_temp($hhvm, $test);
    list($hhvm, $hhvm_env) = hhvm_cmd($options, $hhas_temp);
  }

  if ($outputs = run_config_server($options, $test)) {
    return run_config_post($outputs, $test, $options) ? 'pass-server'
      : (run_one_config($options, $test, $hhvm, $hhvm_env) ? 'skip-server'
         : false);
  }
  return run_one_config($options, $test, $hhvm, $hhvm_env);
}

function num_cpus() {
  switch(PHP_OS) {
    case 'Linux':
      $data = file('/proc/stat');
      $cores = 0;
      foreach($data as $line) {
        if (preg_match('/^cpu[0-9]/', $line)) {
          $cores++;
        }
      }
      return $cores;
    case 'Darwin':
    case 'FreeBSD':
      return exec('sysctl -n hw.ncpu');
  }
  return 2; // default when we don't know how to detect
}

function make_header($str) {
  return "\n\033[0;33m".$str."\033[0m\n";
}

function print_commands($tests, $options) {
  print make_header("Run these by hand:");

  foreach ($tests as $test) {
    list($command, $_) = hhvm_cmd($options, $test);
    if (!isset($options['repo'])) {
      print "$command\n";
      continue;
    }

    // How to run it with hhbbc:
    $hhbbc_cmds = hphp_cmd($options, $test)."\n";
    $hhbbc_cmds .= hhbbc_cmd($options, $test)."\n";
    $hhbbc_cmds .= $command."\n";
    print "$hhbbc_cmds\n";
  }
}

function msg_loop($num_tests, $queue) {
  $passed = 0;
  $skipped = 0;
  $failed = 0;

  $do_progress = Status::getMode() == Status::MODE_NORMAL &&
    Status::hasCursorControl();
  if ($do_progress) {
    $stty = strtolower(Status::getSTTY());
    preg_match_all("/columns ([0-9]+);/", $stty, $output);
    if (!isset($output[1][0])) {
      // because BSD has to be different
      preg_match_all("/([0-9]+) columns;/", $stty, $output);
    }
    if (!isset($output[1][0])) {
      $do_progress = false;
    } else {
      $cols = $output[1][0];
    }
  }

  while (true) {
    if (!msg_receive($queue, 0, $type, 1024, $message)) {
      error("msg_receive failed");
    }

    switch ($type) {
      case Status::MSG_FINISHED:
        break 2;

      case Status::MSG_SERVER_RESTARTED:
        switch (Status::getMode()) {
          case Status::MODE_NORMAL:
            if (!Status::hasCursorControl()) {
              Status::sayColor(Status::RED, 'x');
            }
            break;

          case Status::MODE_VERBOSE:
            Status::sayColor("$test ", Status::YELLOW, "failed",
                             " to talk to server\n");
            break;

          case Status::MODE_FBMAKE:
            break;
        }

      case Status::MSG_TEST_PASS:
        $passed++;
        list($test, $how, $time, $stime, $etime) = $message;
        switch (Status::getMode()) {
          case Status::MODE_NORMAL:
            if (!Status::hasCursorControl()) {
              if ($how == Status::SKIP_SERVER) {
                Status::sayColor(Status::RED, '.');
              } else {
                Status::sayColor(Status::GREEN,
                                 $how == Status::PASS_SERVER ? ',' : '.');
              }
            }
            break;

          case Status::MODE_VERBOSE:
            Status::sayColor("$test ", Status::GREEN,
                             sprintf("passed (%.2fs)\n", $time));
            break;

          case Status::MODE_FBMAKE:
            Status::sayFBMake($test, 'passed', $stime, $etime);
            break;
        }
        break;

      case Status::MSG_TEST_SKIP:
        $skipped++;
        list($test, $reason, $time, $stime, $etime) = $message;

        switch (Status::getMode()) {
          case Status::MODE_NORMAL:
            if (!Status::hasCursorControl()) {
              Status::sayColor(Status::YELLOW, 's');
            }
            break;

          case Status::MODE_VERBOSE:
            Status::sayColor("$test ", Status::YELLOW, "skipped");

            if ($reason !== null) {
              Status::sayColor(" - $reason");
            }
            Status::sayColor(sprintf(" (%.2fs)\n", $time));
            break;
        }
        break;

      case Status::MSG_TEST_FAIL:
        $failed++;
        list($test, $time, $stime, $etime) = $message;
        switch (Status::getMode()) {
          case Status::MODE_NORMAL:
            if (Status::hasCursorControl()) {
              print "\033[2K\033[1G";
            }
            $diff = (string)@file_get_contents($test.'.diff');
            Status::sayColor(Status::RED, "\nFAILED",
                             ": $test\n$diff\n");
            break;

          case Status::MODE_VERBOSE:
            Status::sayColor("$test ", Status::RED,
                             sprintf("FAILED (%.2fs)\n", $time));
            break;

          case Status::MODE_FBMAKE:
            Status::sayFBMake($test, 'failed', $stime, $etime);
            break;
        }
        break;

      default:
        error("Unknown message $type");
    }

    if ($do_progress) {
      $total_run = ($skipped + $failed + $passed);
      $bar_cols = ($cols - 45);

      $passed_ticks  = round($bar_cols * ($passed  / $num_tests));
      $skipped_ticks = round($bar_cols * ($skipped / $num_tests));
      $failed_ticks  = round($bar_cols * ($failed  / $num_tests));

      $fill = $bar_cols - ($passed_ticks + $skipped_ticks + $failed_ticks);
      if ($fill < 0) $fill = 0;

      $fill = str_repeat('-', $fill);

      $passed_ticks = str_repeat('#',  $passed_ticks);
      $skipped_ticks = str_repeat('#', $skipped_ticks);
      $failed_ticks = str_repeat('#',  $failed_ticks);

      print "\033[2K\033[1G[".
        "\033[0;32m$passed_ticks".
        "\033[33m$skipped_ticks".
        "\033[31m$failed_ticks".
        "\033[0m$fill] ($total_run/$num_tests) ".
        "($skipped skipped, $failed failed)";
    }
  }

  if ($do_progress) {
    print "\033[2K\033[1G";
    if ($skipped > 0) {
      print "$skipped tests \033[1;33mskipped\033[0m\n";
    }
  }
}

function print_success($tests, $results, $options) {
  // We didn't run any tests, not even skipped. Clowntown!
  if (!$tests) {
    print "\nCLOWNTOWN: No tests!\n";
    if (empty($options['no-fun'])) {
      print <<<CLOWN
            _
           {_}
           /*\\
          /_*_\\
         {('o')}
      C{{([^*^])}}D
          [ * ]
         /  Y  \\
        _\\__|__/_
       (___/ \\___)
CLOWN
        ."\n\n";
    }

    /* Emacs' syntax highlighting gets confused by that clown and this comment
     * resets whatever state got messed up. */
    return;
  }
  $ran_tests = false;
  foreach ($results as $result) {
    // The result here will either be skipped or passed (since failed is
    // handled in print_failure
    if ($result['status'] == 'passed') {
      $ran_tests = true;
      break;
    }
  }
  // We just had skipped tests
  if (!$ran_tests) {
    print "\nSKIP-ALOO: Only skipped tests!\n";
    if (empty($options['no-fun'])) {
      print <<<SKIPPER
                        .".
                         /  |
                        /  /
                       / ,"
           .-------.--- /
          "._ __.-/ o. o\
             "   (    Y  )
                  )     /
                 /     (
                /       Y
            .-"         |
           /  _     \    \
          /    `. ". ) /' )
         Y       )( / /(,/
        ,|      /     )
       ( |     /     /
        " \_  (__   (__
            "-._,)--._,)
SKIPPER
        ."\n\n";
    }

    /* Emacs' syntax highlighting may get confused by the skipper and this
     * rcomment esets whatever state got messed up. */
    return;
  }
  print "\nAll tests passed.\n";
  if (empty($options['no-fun'])) {
    print <<<SHIP
              |    |    |
             )_)  )_)  )_)
            )___))___))___)\
           )____)____)_____)\\
         _____|____|____|____\\\__
---------\      SHIP IT      /---------
  ^^^^^ ^^^^^^^^^^^^^^^^^^^^^
    ^^^^      ^^^^     ^^^    ^^
         ^^^^      ^^^
SHIP
      ."\n";
  }
  if (isset($options['verbose'])) {
    print_commands($tests, $options);
  }
}

function print_failure($argv, $results, $options) {
  $failed = array();
  $passed = array();
  foreach ($results as $result) {
    if ($result['status'] === 'failed') {
      $failed[] = $result['name'];
    }
    if ($result['status'] === 'passed') {
      $passed[] = $result['name'];
    }
  }
  asort($failed);
  print "\n".count($failed)." tests failed\n";
  if (empty($options['no-fun'])) {
    print "(╯°□°）╯︵ ┻━┻\n";
  }

  print make_header("See the diffs:").
    implode("\n", array_map(
      function($test) { return 'cat '.$test.'.diff'; },
    $failed))."\n";

  $failing_tests_file = !empty($options['failure-file'])
    ? $options['failure-file']
    : tempnam('/tmp', 'test-failures');
  file_put_contents($failing_tests_file, implode("\n", $failed)."\n");
  print make_header('For xargs, list of failures is available using:').
    'cat '.$failing_tests_file."\n";

  if (!empty($passed)) {
    $passing_tests_file = !empty($options['success-file'])
      ? $options['success-file']
      : tempnam('/tmp', 'tests-passed');
    file_put_contents($passing_tests_file, implode("\n", $passed)."\n");
    print make_header('For xargs, list of passed tests is available using:').
      'cat '.$passing_tests_file."\n";
  }

  print_commands($failed, $options);

  $rerun = make_header("Re-run just the failing tests:") . $argv[0];
  foreach ($options as $option => $value) {
    if ($option === "threads") {
      // e.g., For a small number of failed tests, we don't need max threads
      $rerun .= " --" . $option . ' ' . get_num_threads($options, $failed);
    } else {
      $rerun .= " --" . $option . ' ' . escapeshellarg($value);
    }
  }
  $rerun .= ' ' . implode(' ', array_map('escapeshellarg', $failed)) . "\n";
  print $rerun;
}

function port_is_listening($port) {
  $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
  return @socket_connect($socket, 'localhost', $port);
}

function find_open_port() {
  for ($i = 0; $i < 50; ++$i) {
    $port = rand(1024, 65535);
    if (!port_is_listening($port)) return $port;
  }

  error("Couldn't find an open port");
}

function start_server_impl($options, $config, $port) {
  $threads = $options['threads'];
  $command = hhvm_cmd_impl(
    $options,
    $config,
    '-m', 'server',
    "-vServer.Port=$port",
    "-vAdminServer.Port=0",
    "-vServer.ThreadCount=$threads",
    '-vServer.ExitOnBindFail=1',
    '-vServer.RequestTimeoutSeconds='.SERVER_TIMEOUT,
    '-vPageletServer.ThreadCount=0',
    '-vLog.UseRequestLog=1',
    '-vLog.File=/dev/null',

    // This ensures we actually jit everything:
    '-vEval.JitRequireWriteLease=1',

    // The default test config uses a small TC but we'll be running thousands
    // of tests against the same process:
    '-vEval.JitASize=100000000',
    '-vEval.JitGlobalDataSize=32000000'
  );
  if (getenv('HHVM_TEST_SERVER_LOG')) {
    echo "Starting server '$command'\n";
  }

  $descriptors = array(
    0 => array('file', '/dev/null', 'r'),
    1 => array('file', '/dev/null', 'w'),
    2 => array('file', '/dev/null', 'w'),
  );

  $proc = proc_open($command, $descriptors, $dummy);
  if (!$proc) {
    error("Failed to start server process");
  }
  $status = proc_get_status($proc);
  $status['proc'] = $proc;
  $status['port'] = $port;
  $status['config'] = $config;
  return $status;
}

function start_server($options, $config) {
  for ($i = 0; $i < 5; ++$i) {
    $port = find_open_port();
    $status = start_server_impl($options, $config, $port);

    // Wait for the server to start responding. If someone else bound to $port
    // between the call to find_open_port and the call to proc_open, then we
    // might start talking to them thinking it's our server.
    for ($j = 0; $j < 40; ++$j) {
      $new_status = proc_get_status($status['proc']);

      if (!$new_status['running']) {
        if ($new_status['exitcode'] === 0) {
          error("Server exited prematurely but without error");
        }

        // We lost a race. Try another port
        if (getenv('HHVM_TEST_SERVER_LOG')) {
          echo "\n\nLost connection race on port $port. Trying another.\n\n";
        }
        continue 2;
      }

      if (port_is_listening($port)) {
        return $status;
      }

      usleep(250000);
    }
    error("Server took too long to come up");
  }

  error("Couldn't start server");
}

/*
 * For each config file in $configs, start up a server on a randomly-determined
 * port. Return value is an array mapping pids and config files to arrays of
 * information about the server.
 */
function start_servers($options, $configs) {
  $servers = array('pids' => array(), 'configs' => array());
  foreach ($configs as $config) {
    $server = start_server($options, $config);
    $servers['pids'][$server['pid']] =& $server;
    $servers['configs'][$server['config']] =& $server;
    unset($server);
  }
  return $servers;
}

function drain_queue($queue) {
  while (@msg_receive($queue, 0, $type, 1024, $message, true,
                      MSG_IPC_NOWAIT | MSG_NOERROR));
}

function get_num_threads($options, $tests) {
  $cpus = isset($options['server']) ? num_cpus() * 2 : num_cpus();
  return min(count($tests), idx($options, 'threads', $cpus));
}

function runner_precheck() {
  // basic checking for runner
  if (empty($_SERVER) || empty($_ENV)) {
    echo "Warning: \$_SERVER/\$_ENV variables not available, please check \n" .
         "your ini setting: variables_order, it should have both 'E' and 'S'\n";
  }
}

function main($argv) {
  runner_precheck();

  ini_set('pcre.backtrack_limit', PHP_INT_MAX);

  list($options, $files) = get_options($argv);
  if (isset($options['help'])) {
    error(help());
  }
  if (isset($options['list-tests'])) {
    error(list_tests());
  }

  $tests = find_tests($files, $options);
  if (isset($options['shuffle'])) {
    shuffle($tests);
  }

  // Explicit path given by --hhvm-binary-path takes priority
  // Then, if an HHVM_BIN env var exists, and the file it points to exists,
  // that trumps any default hhvm executable path
  $binary_path = "";
  if (isset($options['hhvm-binary-path'])) {
    $binary_path = realpath($options['hhvm-binary-path']);
    check_hhvm_executable($binary_path);
    putenv("HHVM_BIN=" . $binary_path);
  } else if (getenv("HHVM_BIN") !== false) {
    $binary_path = realpath(getenv("HHVM_BIN"));
    check_hhvm_executable($binary_path);
  } else {
    check_for_multiple_default_hhvm_binaries();
    $binary_path = hhvm_path();
  }

  if (isset($options['verbose'])) {
    print "You are using the binary located at: " . $binary_path . "\n";
  }

  $options['threads'] = get_num_threads($options, $tests);

  $servers = null;
  if (isset($options['server'])) {
    if (isset($options['repo'])) {
      error("Server mode repo tests are not supported");
    }
    $configs = array();

    /* We need to start up a separate server process for each config file
     * found. */
    foreach ($tests as $test) {
      if (!can_run_server_test($test)) continue;
      $config = find_file_for_dir(dirname($test), 'config.ini');
      if (!$config) {
        error("Couldn't find config file for $test");
      }
      $configs[$config] = $config;
    }

    $max_configs = 10;
    if (count($configs) > $max_configs) {
      error("More than $max_configs unique config files will be needed to run ".
            "the tests you specified. They may not be a good fit for server ".
            "mode.");
    }

    $servers = $options['servers'] = start_servers($options, $configs);
  }

  // Try to construct the buckets so the test results are ready in
  // approximately alphabetical order
  $test_buckets = array();
  $i = 0;
  $serial_tests = serial_only_tests();
  foreach ($tests as $test) {
    if (!in_array($test, $serial_tests)) {
      $test_buckets[$i][] = $test;
      $i = ($i + 1) % $options['threads'];
    }
  }

  $actual_serial_count_for_this_run = 0;
  foreach ($serial_tests as $test) {
    // Only populate a test into the serial bucket if it was in the original
    // test list provided by user to begin with. All serial tests are, of
    // course, in the same bucket so they run sequentially.
    if (in_array($test, $tests)) {
      $test_buckets[$i][] = $test;
      $actual_serial_count_for_this_run++;
    }
  }
  // Get the threads right after accounting for serial only tests since
  // originally we accounted for the serial tests in the number of threads
  if (count($test_buckets) < $options['threads']) {
    $options['threads'] = count($test_buckets);
  }

  if (!isset($options['fbmake'])) {
    print "Running ".count($tests)." tests in ".
      $options['threads']." threads (" . $actual_serial_count_for_this_run .
      " in serial)\n";
  }

  if (isset($options['verbose'])) {
    Status::setMode(Status::MODE_VERBOSE);
  }
  if (isset($options['fbmake'])) {
    Status::setMode(Status::MODE_FBMAKE);
  }
  Status::setUseColor(isset($options['color']) ? true : posix_isatty(STDOUT));

  Status::$key = rand();
  $queue = Status::getQueue();
  drain_queue($queue);

  // Spawn off worker threads
  $children = array();
  // A poor man's shared memory
  $bad_test_files = array();
  for ($i = 0; $i < $options['threads']; $i++) {
    $bad_test_file = tempnam('/tmp', 'test-run-');
    $bad_test_files[] = $bad_test_file;
    $pid = pcntl_fork();
    if ($pid == -1) {
      error('could not fork');
    } else if ($pid) {
      $children[$pid] = $pid;
    } else {
      exit(run($options, $test_buckets[$i], $bad_test_file));
    }
  }

  // Fork off a child to receive messages and print status, and have the parent
  // wait for all children to exit.
  $printer_pid = pcntl_fork();
  if ($printer_pid == -1) {
    error("failed to fork");
  } else if ($printer_pid == 0) {
    msg_loop(count($tests), $queue);
    return 0;
  }

  // In case we exit in a crazy way, have the parent blow up the queue.
  // Do this here so no children inherit this.
  $kill_queue = function() { Status::killQueue(); };
  register_shutdown_function($kill_queue);
  pcntl_signal(SIGTERM, $kill_queue);
  pcntl_signal(SIGINT, $kill_queue);

  $return_value = 0;
  while (count($children) && $printer_pid != 0) {
    $pid = pcntl_wait($status);
    if (!pcntl_wifexited($status) && !pcntl_wifsignaled($status)) {
      error("Unexpected exit status from child");
    }

    if ($pid == $printer_pid) {
      // We should be finishing up soon.
      $printer_pid = 0;
    } else if (isset($servers['pids'][$pid])) {
      // A server crashed. Restart it.
      if (getenv('HHVM_TEST_SERVER_LOG')) {
        echo "\nServer $pid crashed. Restarting.\n";
      }
      Status::serverRestarted();
      $server =& $servers['pids'][$pid];
      $server = start_server_impl($options, $server['config'], $server['port']);

      // Unset the old $pid entry and insert the new one.
      unset($servers['pids'][$pid]);
      $servers['pids'][$server['pid']] =& $server;
      unset($server);
    } else {
      if (!isset($children[$pid])) {
        error("Unexpected child pid $pid from wait");
      }
      unset($children[$pid]);
      $return_value |= pcntl_wexitstatus($status);
    }
  }

  Status::finished();

  // Kill the server
  if ($servers) {
    foreach ($servers['pids'] as $server) {
      proc_terminate($server['proc']);
      proc_close($server['proc']);
    }
  }

  $results = array();
  foreach ($bad_test_files as $bad_test_file) {
    $json = json_decode(file_get_contents($bad_test_file), true);
    if (!is_array($json)) {
      error(
        "\nNo JSON output was received from a test thread. ".
        "Either you killed it, or it might be a bug in the test script."
      );
    }
    $results = array_merge($results, $json);
    unlink($bad_test_file);
  }

  if (isset($options['fbmake'])) {
    Status::say(array('op' => 'all_done', 'results' => $results));
    return $return_value;
  }

  if (!$return_value) {
    print_success($tests, $results, $options);
    return $return_value;
  }
  print_failure($argv, $results, $options);
  return $return_value;
}

exit(main($argv));
