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

function get_expect_file($test, $type, $options) {
  if (isset($options['repo']) && file_exists("$test.$type-repo")) {
      return "$test.$type-repo";
  }
  return "$test.$type";
}

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/fbcode/hphp/test/run"

Examples:

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

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

  # 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.HHIRJumpOpts=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

EOT;
  return usage().$help;
}

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

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

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

function idx_file($array, $key, $default = null) {
  $file = is_file(idx($array, $key)) ? realpath($array[$key]) : $default;
  if (!is_file($file)) {
    error("$file doesn't exist. Did you forget to build first?");
  }
  return rel_path($file);
}

function bin_root() {
  $dir = hphp_home() . '/' . idx($_ENV, 'FBMAKE_BIN_ROOT', '_bin');
  return is_dir($dir) ?
    $dir :      # fbmake
    hphp_home() # github
  ;
}

function verify_hhbc() {
  return idx($_ENV, 'VERIFY_HHBC', 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' => '',
    'bccf' => '',
    'hhas-round-trip' => '',
    'color' => 'c',
    'no-fun' => '',
    'cores' => '',
  );
  $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',
    'debugger'   => 'hphp/test/server/debugger/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];
}

function find_tests($files, array $options = null) {
  if (!$files) {
    $files = array('quick');
  }
  if ($files == array('all')) {
    $files = array('quick', 'slow', 'zend');
  }
  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 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) {
  while (($dir !== '.' && $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':
    case 'automain':
      return "$jit_args";
    case 'pgo':
      return "$jit_args -vEval.JitPGO=1 -vEval.JitRegionSelector=hottrace ".
             "-vEval.JitPGOHotOnly=0";
    case 'interp':
      return "$repo_args -vEval.Jit=0";
    default:
      error("-m must be one of jit | pgo | interp | automain. Got: '$mode'");
  }
}

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

function hhvm_path() {
  return idx_file($_ENV, 'HHVM_BIN', bin_root().'/hphp/hhvm/hhvm');
}

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

  $extra_args = array_slice(func_get_args(), 2);
  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) {
  $use_automain = false;
  if ($test_run === null) {
    $use_automain = 'automain' === idx($options, 'mode')
      && 'php' === pathinfo($test, PATHINFO_EXTENSION);
    $test_run = $use_automain
      ? __DIR__.'/tools/pseudomain_wrapper.php'
      : $test;
  }
  $cmd = hhvm_cmd_impl(
    $options,
    find_test_ext($test, 'ini'),
    find_debug_config($test, 'hphpd.ini'),
    read_file(find_test_ext($test, 'opts')),
    '--file',
    escapeshellarg($test_run),
    $use_automain ? escapeshellarg($test) : ''
  );

  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 (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;
  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 pass($test, $detail) {
    array_push(self::$results, array('name' => $test, 'status' => 'passed'));
    $how = $detail === 'pass-server' ? self::PASS_SERVER :
      ($detail === 'skip-server' ? self::SKIP_SERVER : self::PASS_CLI);
    self::send(self::MSG_TEST_PASS, [$test, $how]);
  }

  public static function skip($test, $reason = null) {
    self::send(self::MSG_TEST_SKIP, [$test,$reason]);
  }

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

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

  private static function send($type, $msg) {
    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 preceeded 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) {
    $start = array('op' => 'start', 'test' => $test);
    $end = array('op' => 'test_done', 'test' => $test, 'status' => $status);
    if ($status == 'failed') {
      $end['details'] = (string)@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() {
    return !getenv("TRAVIS")
      && !exec('stty -a | grep -e "\\berase = <undef>"');
  }

  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 run($options, $tests, $bad_test_file) {
  foreach ($tests as $test) {
    $status = run_test($options, $test);
    if ($status === 'skip') {
      Status::skip($test);
    } else if ($status === 'skip-norepo') {
      Status::skip($test, 'norepo');
    } else if ($status === 'skip-onlyrepo') {
      Status::skip($test, 'onlyrepo');
    } else if ($status) {
      Status::pass($test, $status);
    } else {
      Status::fail($test);
    }
  }
  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 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);
  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;
  }

  if (file_exists("$test.expect")) {
    $file = get_expect_file($test, 'expect', $options);
    $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 (file_exists("$test.expectf")) {
    $file = get_expect_file($test, 'expectf', $options);
    $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);
    $wanted_re = str_replace('%f', '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?',
                             $wanted_re);
    $wanted_re = str_replace('%c', '.', $wanted_re);
    // %f allows two points "-.0.0" but that is the best *simple* expression

    // 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 (file_exists("$test.expectregex")) {
    $file = get_expect_file($test, 'expectregex', $options);
    $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;
  }
}

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_test($options, $test) {
  if (skip_test($options, $test)) return 'skip';

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

  $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\" ".
               "\"$test.repo/CodeError.js\"");
    $hphp = hphp_cmd($options, $test);
    $hhbbc = hhbbc_cmd($options, $test);
    shell_exec("$hphp 2>&1");
    if (file_exists("$test.code_error") &&
        file_exists("$test.repo/CodeError.js")) {
      file_put_contents("$test.diff",
                        "Test failed because CodeError.js was not empty: ".
                        file_get_contents("$test.repo/CodeError.js"));
      return false;
    }
    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(exec('stty -a | grep columns'));
    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) = $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, "passed\n");
            break;

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

      case Status::MSG_TEST_SKIP:
        $skipped++;
        list($test, $reason) = $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("\n");
            break;
        }
        break;

      case Status::MSG_TEST_FAIL:
        $failed++;
        $test = $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, "FAILED\n");
            break;

          case Status::MODE_FBMAKE:
            Status::sayFBMake($test, 'failed');
            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, $options) {
  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;
  }
  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);

  print make_header("Re-run just the failing tests:").
        $argv[0].' '.implode(' ', $failed)."\n";

  if (idx($options, 'mode') == 'automain') {
    print make_header(
      'Automain caveat: wrapper script may change semantics');
    print 'The automain wrapper script '
      .__DIR__.'/tools/pseudomain_wrapper.php'
      .' may have changed behavior'."\n"
      .'(e.g. extra frames in backtraces) by moving code from '
      .'pseudo-main to file top-level and adding a wrapper function.'
      ."\n";
  }
}

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) {
  if (idx($options, 'mode') === 'automain') {
    error("Mode 'automain' not supported in server mode");
  }

  $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.JitAStubsSize=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 main($argv) {
  ini_set('pcre.backtrack_limit', PHP_INT_MAX);

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

  hhvm_path(); // check that binary exists

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

  $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);
  }

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

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

  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 = ftok(__FILE__, 't');
  $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);
    msg_remove_queue($queue);
    return 0;
  }

  $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(
        "No JSON output was received from a test thread. ".
        "This might be a bug in the test script."
      );
    }
    $results = array_merge($results, $json);
  }

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

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

  print_failure($argv, $results, $options);
  return $return_value;
}

exit(main($argv));
