// Copyright 2018-2026 the Deno authors. MIT license.

use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::io::BufRead;
use std::io::BufReader;
use std::io::ErrorKind;
use std::io::Read;
use std::io::Write;
use std::ops::Deref;
use std::ops::DerefMut;
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;

use file_test_runner::TestResult;
use os_pipe::pipe;
use parking_lot::Mutex;

use crate::HttpServerGuard;
use crate::TempDir;
use crate::assertions::assert_wildcard_match;
use crate::assertions::assert_wildcard_match_with_logger;
use crate::deno_exe_path;
use crate::denort_exe_path;
use crate::env_vars_for_jsr_tests;
use crate::env_vars_for_npm_tests;
use crate::eprintln;
use crate::fs::PathRef;
use crate::http_server;
use crate::jsr_registry_unset_url;
#[cfg(feature = "lsp")]
use crate::lsp::LspClientBuilder;
use crate::nodejs_org_mirror_unset_url;
use crate::npm_registry_unset_url;
use crate::print::spawn_thread;
use crate::println;
use crate::pty::Pty;
use crate::strip_ansi_codes;
use crate::testdata_path;
use crate::tests_path;

// Gives the developer a nice error message if they have a deno configuration
// file that will be auto-discovered by the tests and cause a lot of failures.
static HAS_DENO_JSON_IN_WORKING_DIR_ERR: once_cell::sync::Lazy<Option<String>> =
  once_cell::sync::Lazy::new(|| {
    let testdata_path = testdata_path();
    let mut current_dir = testdata_path.as_path();
    let deno_json_names = ["deno.json", "deno.jsonc"];
    loop {
      for name in deno_json_names {
        let deno_json_path = current_dir.join(name);
        if deno_json_path.exists() {
          return Some(format!(
            concat!(
              "Found deno configuration file at {}. The test suite relies on ",
              "a deno.json not existing in any ancestor directory. Please ",
              "delete this file so the tests won't auto-discover it.",
            ),
            deno_json_path.display(),
          ));
        }
      }
      if let Some(parent) = current_dir.parent() {
        current_dir = parent;
      } else {
        break;
      }
    }

    None
  });

#[derive(Default, Clone)]
enum DiagnosticLogger {
  Null,
  #[default]
  Stderr,
  Container(Rc<RefCell<Vec<u8>>>),
}

impl DiagnosticLogger {
  pub fn writeln(&self, text: impl AsRef<str>) {
    match self {
      DiagnosticLogger::Null => {}
      DiagnosticLogger::Stderr => {
        eprintln!("{}", text.as_ref());
      }
      DiagnosticLogger::Container(logger) => {
        let mut logger = logger.borrow_mut();
        logger.write_all(text.as_ref().as_bytes()).unwrap();
        logger.write_all(b"\n").unwrap();
      }
    }
  }
}

#[derive(Default)]
pub struct TestContextBuilder {
  diagnostic_logger: DiagnosticLogger,
  use_http_server: bool,
  use_temp_cwd: bool,
  use_symlinked_temp_dir: bool,
  use_canonicalized_temp_dir: bool,
  /// Copies the files at the specified directory in the "testdata" directory
  /// to the temp folder and runs the test from there. This is useful when
  /// the test creates files in the testdata directory (ex. a node_modules folder)
  copy_temp_dir: Option<String>,
  temp_dir_path: Option<PathBuf>,
  cwd: Option<String>,
  envs: HashMap<String, String>,
}

impl TestContextBuilder {
  pub fn new() -> Self {
    Self::default().add_compile_env_vars()
  }

  pub fn for_npm() -> Self {
    Self::new().use_http_server().add_npm_env_vars()
  }

  pub fn for_jsr() -> Self {
    Self::new().use_http_server().add_jsr_env_vars()
  }

  pub fn logging_capture(mut self, logger: Rc<RefCell<Vec<u8>>>) -> Self {
    self.diagnostic_logger = DiagnosticLogger::Container(logger);
    self
  }

  pub fn temp_dir_path(mut self, path: impl AsRef<Path>) -> Self {
    self.temp_dir_path = Some(path.as_ref().to_path_buf());
    self
  }

  pub fn use_http_server(mut self) -> Self {
    self.use_http_server = true;
    self
  }

  pub fn use_temp_cwd(mut self) -> Self {
    self.use_temp_cwd = true;
    self
  }

  /// Causes the temp directory to be symlinked to a target directory
  /// which is useful for debugging issues that only show up on the CI.
  ///
  /// Note: This method is not actually deprecated, it's just the CI
  /// does this by default so there's no need to check in any code that
  /// uses this into the repo. This is just for debugging purposes.
  #[deprecated]
  pub fn use_symlinked_temp_dir(mut self) -> Self {
    self.use_symlinked_temp_dir = true;
    self
  }

  /// Causes the temp directory to go to its canonicalized path instead
  /// of being in a symlinked temp dir on the CI.
  ///
  /// Note: This method is not actually deprecated. It's just deprecated
  /// to discourage its use. Use it sparingly and document why you're using
  /// it. You better have a good reason other than being lazy!
  ///
  /// If your tests are failing because the temp dir is symlinked on the CI,
  /// then it likely means your code doesn't properly handle when Deno is running
  /// in a symlinked directory. That's a bug and you should fix it without using
  /// this.
  #[deprecated]
  pub fn use_canonicalized_temp_dir(mut self) -> Self {
    self.use_canonicalized_temp_dir = true;
    self
  }

  /// Copies the files at the specified directory in the "testdata" directory
  /// to the temp folder and runs the test from there. This is useful when
  /// the test creates files in the testdata directory (ex. a node_modules folder)
  pub fn use_copy_temp_dir(mut self, dir: impl AsRef<str>) -> Self {
    self.copy_temp_dir = Some(dir.as_ref().to_string());
    self
  }

  pub fn cwd(mut self, cwd: impl AsRef<str>) -> Self {
    self.cwd = Some(cwd.as_ref().to_string());
    self
  }

  pub fn envs<I, K, V>(self, vars: I) -> Self
  where
    I: IntoIterator<Item = (K, V)>,
    K: AsRef<str>,
    V: AsRef<str>,
  {
    let mut this = self;
    for (key, value) in vars {
      this = this.env(key, value);
    }
    this
  }

  pub fn env(mut self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
    self
      .envs
      .insert(key.as_ref().to_string(), value.as_ref().to_string());
    self
  }

  pub fn add_npm_env_vars(mut self) -> Self {
    for (key, value) in env_vars_for_npm_tests() {
      self = self.env(key, value);
    }
    self
  }

  pub fn add_compile_env_vars(mut self) -> Self {
    // The `denort` binary is in the same artifact directory as the `deno` binary.
    let denort_bin = denort_exe_path();
    self = self.env("DENORT_BIN", denort_bin.to_string());
    self
  }

  pub fn add_jsr_env_vars(mut self) -> Self {
    for (key, value) in env_vars_for_jsr_tests() {
      self = self.env(key, value);
    }
    self
  }

  pub fn build(&self) -> TestContext {
    if let Some(err) = &*HAS_DENO_JSON_IN_WORKING_DIR_ERR {
      panic!("{}", err);
    }

    let temp_dir_path = PathRef::new(
      self
        .temp_dir_path
        .clone()
        .unwrap_or_else(std::env::temp_dir),
    );
    let temp_dir_path = if self.use_canonicalized_temp_dir {
      temp_dir_path.canonicalize()
    } else {
      temp_dir_path
    };
    let deno_dir = TempDir::new_in(temp_dir_path.as_path());
    let temp_dir = TempDir::new_in(temp_dir_path.as_path());
    let temp_dir = if self.use_symlinked_temp_dir {
      assert!(!self.use_canonicalized_temp_dir); // code doesn't handle using both of these
      TempDir::new_symlinked(temp_dir)
    } else {
      temp_dir
    };
    if let Some(temp_copy_dir) = &self.copy_temp_dir {
      let test_data_path = testdata_path().join(temp_copy_dir);
      let temp_copy_dir = temp_dir.path().join(temp_copy_dir);
      temp_copy_dir.create_dir_all();
      test_data_path.copy_to_recursive(&temp_copy_dir);
    }

    let deno_exe = deno_exe_path();

    let http_server_guard = if self.use_http_server {
      Some(Rc::new(http_server()))
    } else {
      None
    };

    let cwd = if self.use_temp_cwd || self.copy_temp_dir.is_some() {
      temp_dir.path().to_owned()
    } else {
      testdata_path().clone()
    };
    let cwd = match &self.cwd {
      Some(specified_cwd) => cwd.join(specified_cwd),
      None => cwd,
    };

    // Point `deno check` at a pre-downloaded native compiler so each test's
    // fresh temp `DENO_DIR` doesn't re-download it. `tools/download_tsc.ts`
    // materializes it under `<target>/.native_tsc/deno_dir`; the harness resolves
    // that path itself and injects `DENO_TSC_BIN` per-test (like `JSR_URL`),
    // instead of relying on a CI script exporting it into the ambient env. An
    // explicit ambient `DENO_TSC_BIN` (e.g. a local `export`) still wins, as does
    // a value a test sets itself.
    let mut envs = self.envs.clone();
    if !envs.contains_key("DENO_TSC_BIN") {
      let tsc_bin = std::env::var_os("DENO_TSC_BIN")
        .map(|v| v.to_string_lossy().into_owned())
        .or_else(|| {
          crate::native_tsc_bin_path().map(|p| p.to_string_lossy().into_owned())
        });
      if let Some(tsc_bin) = tsc_bin {
        envs.insert("DENO_TSC_BIN".to_string(), tsc_bin);
      }
    }

    TestContext {
      cwd,
      deno_exe,
      envs,
      diagnostic_logger: self.diagnostic_logger.clone(),
      _http_server_guard: http_server_guard,
      deno_dir,
      temp_dir,
    }
  }
}

#[derive(Clone)]
pub struct TestContext {
  deno_exe: PathRef,
  diagnostic_logger: DiagnosticLogger,
  envs: HashMap<String, String>,
  cwd: PathRef,
  _http_server_guard: Option<Rc<HttpServerGuard>>,
  deno_dir: TempDir,
  temp_dir: TempDir,
}

impl Default for TestContext {
  fn default() -> Self {
    TestContextBuilder::default().build()
  }
}

impl TestContext {
  pub fn with_http_server() -> Self {
    TestContextBuilder::new().use_http_server().build()
  }

  pub fn deno_dir(&self) -> &TempDir {
    &self.deno_dir
  }

  pub fn temp_dir(&self) -> &TempDir {
    &self.temp_dir
  }

  pub fn new_command(&self) -> TestCommandBuilder {
    TestCommandBuilder::new(self.deno_dir.clone())
      .set_diagnostic_logger(self.diagnostic_logger.clone())
      .envs(self.envs.clone())
      .current_dir(&self.cwd)
  }

  #[cfg(feature = "lsp")]
  pub fn new_lsp_command(&self) -> LspClientBuilder {
    let mut builder = LspClientBuilder::new_with_dir(self.deno_dir.clone())
      .deno_exe(&self.deno_exe)
      .set_root_dir(self.temp_dir.path().clone());
    for (key, value) in &self.envs {
      builder = builder.env(key, value);
    }
    builder
  }

  pub fn run_deno(&self, args: impl AsRef<str>) {
    self
      .new_command()
      .name("deno")
      .args(args)
      .run()
      .skip_output_check();
  }

  pub fn run_npm(&self, args: impl AsRef<str>) {
    self
      .new_command()
      .name("npm")
      .args(args)
      .run()
      .skip_output_check();
  }

  pub fn deno_exe(&self) -> &PathRef {
    &self.deno_exe
  }

  pub fn envs(&self) -> &HashMap<String, String> {
    &self.envs
  }

  pub fn get_jsr_package_integrity(&self, sub_path: &str) -> String {
    use sha2::Digest;
    let url = url::Url::parse(self.envs.get("JSR_URL").unwrap()).unwrap();
    let url = url.join(&format!("{}_meta.json", sub_path)).unwrap();
    let bytes = curl_fetch(url.as_str());
    let mut hasher = sha2::Sha256::new();
    hasher.update(&bytes);
    format!("{:x}", hasher.finalize())
  }
}

fn kill_process(pid: u32) {
  #[cfg(unix)]
  {
    // kill the process tree by first killing children via
    // pkill, then the process itself
    let _ = std::process::Command::new("pkill")
      .args(["-9", "-P", &pid.to_string()])
      .output();
    // SAFETY: We're sending SIGKILL to a process we spawned.
    unsafe {
      libc::kill(pid as i32, libc::SIGKILL);
    }
  }
  #[cfg(not(unix))]
  {
    let _ = std::process::Command::new("taskkill")
      .args(["/F", "/T", "/PID", &pid.to_string()])
      .output();
  }
}

fn curl_fetch(url: &str) -> Vec<u8> {
  let output = std::process::Command::new("curl")
    .args(["--fail", "--silent", "--show-error", "--location", url])
    .output()
    .expect("failed to run curl");
  assert!(
    output.status.success(),
    "curl failed for {url}: {}",
    String::from_utf8_lossy(&output.stderr)
  );
  output.stdout
}

/// We can't clone an stdio, so if someone clones a DenoCmd,
/// we want to set this to `Cloned` and show the user a helpful
/// panic message.
enum StdioContainer {
  Cloned,
  Inner(RefCell<Option<Stdio>>),
}

impl Clone for StdioContainer {
  fn clone(&self) -> Self {
    Self::Cloned
  }
}

impl StdioContainer {
  pub fn new(stdio: Stdio) -> Self {
    Self::Inner(RefCell::new(Some(stdio)))
  }

  pub fn take(&self) -> Stdio {
    match self {
      StdioContainer::Cloned => panic!(
        "Cannot run a command after it was cloned. You need to reset the stdio value."
      ),
      StdioContainer::Inner(inner) => match inner.borrow_mut().take() {
        Some(value) => value,
        None => panic!(
          "Cannot run a command that was previously run. You need to reset the stdio value between runs."
        ),
      },
    }
  }
}

#[derive(Clone)]
pub struct TestCommandBuilder {
  deno_dir: TempDir,
  diagnostic_logger: DiagnosticLogger,
  stdin: Option<StdioContainer>,
  stdout: Option<StdioContainer>,
  stderr: Option<StdioContainer>,
  stdin_text: Option<String>,
  command_name: String,
  cwd: Option<PathRef>,
  envs: HashMap<String, String>,
  envs_remove: HashSet<String>,
  env_clear: bool,
  args_text: String,
  args_vec: Vec<String>,
  split_output: bool,
  show_output: bool,
  timeout: Option<std::time::Duration>,
}

impl TestCommandBuilder {
  pub fn new(deno_dir: TempDir) -> Self {
    Self {
      deno_dir,
      diagnostic_logger: Default::default(),
      stdin: None,
      stdout: None,
      stderr: None,
      stdin_text: None,
      split_output: false,
      cwd: None,
      envs: Default::default(),
      envs_remove: Default::default(),
      env_clear: false,
      command_name: "deno".to_string(),
      args_text: "".to_string(),
      args_vec: Default::default(),
      show_output: false,
      timeout: None,
    }
  }

  pub fn name(mut self, name: impl AsRef<OsStr>) -> Self {
    self.command_name = name.as_ref().to_string_lossy().into_owned();
    self
  }

  pub fn args(mut self, args: impl AsRef<str>) -> Self {
    self.args_text = args.as_ref().to_string();
    self
  }

  pub fn args_vec<I, S>(mut self, args: I) -> Self
  where
    I: IntoIterator<Item = S>,
    S: AsRef<std::ffi::OsStr>,
  {
    self.args_vec.extend(
      args
        .into_iter()
        .map(|s| s.as_ref().to_string_lossy().into_owned()),
    );
    self
  }

  pub fn arg<S>(mut self, arg: S) -> Self
  where
    S: AsRef<std::ffi::OsStr>,
  {
    self
      .args_vec
      .push(arg.as_ref().to_string_lossy().into_owned());
    self
  }

  pub fn env_clear(mut self) -> Self {
    self.env_clear = true;
    self
  }

  pub fn envs<I, K, V>(self, vars: I) -> Self
  where
    I: IntoIterator<Item = (K, V)>,
    K: AsRef<std::ffi::OsStr>,
    V: AsRef<std::ffi::OsStr>,
  {
    let mut this = self;
    for (key, value) in vars {
      this = this.env(key, value);
    }
    this
  }

  pub fn env<K, V>(mut self, key: K, val: V) -> Self
  where
    K: AsRef<std::ffi::OsStr>,
    V: AsRef<std::ffi::OsStr>,
  {
    self.envs.insert(
      key.as_ref().to_string_lossy().into_owned(),
      val.as_ref().to_string_lossy().into_owned(),
    );
    self
  }

  pub fn env_remove<K>(mut self, key: K) -> Self
  where
    K: AsRef<std::ffi::OsStr>,
  {
    self
      .envs_remove
      .insert(key.as_ref().to_string_lossy().into_owned());
    self
  }

  /// Set this to enable streaming the output of the command to stderr.
  ///
  /// Not deprecated, this is just here so you don't accidentally
  /// commit code with this enabled.
  #[deprecated]
  pub fn show_output(mut self) -> Self {
    self.show_output = true;
    self
  }

  pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
    self.timeout = Some(timeout);
    self
  }

  pub fn stdin<T: Into<Stdio>>(mut self, cfg: T) -> Self {
    self.stdin = Some(StdioContainer::new(cfg.into()));
    self
  }

  pub fn stdout<T: Into<Stdio>>(mut self, cfg: T) -> Self {
    self.stdout = Some(StdioContainer::new(cfg.into()));
    self
  }

  pub fn stderr<T: Into<Stdio>>(mut self, cfg: T) -> Self {
    self.stderr = Some(StdioContainer::new(cfg.into()));
    self
  }

  pub fn current_dir<P: AsRef<OsStr>>(mut self, dir: P) -> Self {
    let dir = dir.as_ref().to_string_lossy().into_owned();
    self.cwd = Some(match self.cwd {
      Some(current) => current.join(dir),
      None => PathRef::new(dir),
    });
    self
  }

  pub fn stdin_piped(self) -> Self {
    self.stdin(std::process::Stdio::piped())
  }

  pub fn stdout_piped(self) -> Self {
    self.stdout(std::process::Stdio::piped())
  }

  pub fn stderr_piped(self) -> Self {
    self.stderr(std::process::Stdio::piped())
  }

  pub fn piped_output(self) -> Self {
    self.stdout_piped().stderr_piped()
  }

  pub fn stdin_text(mut self, text: impl AsRef<str>) -> Self {
    self.stdin_text = Some(text.as_ref().to_string());
    self.stdin_piped()
  }

  /// Splits the output into stdout and stderr rather than having them combined.
  pub fn split_output(mut self) -> Self {
    // Note: it was previously attempted to capture stdout & stderr separately
    // then forward the output to a combined pipe, but this was found to be
    // too racy compared to providing the same combined pipe to both.
    self.split_output = true;
    self
  }

  pub fn disable_diagnostic_logging(self) -> Self {
    self.set_diagnostic_logger(DiagnosticLogger::Null)
  }

  fn set_diagnostic_logger(mut self, logger: DiagnosticLogger) -> Self {
    self.diagnostic_logger = logger;
    self
  }

  pub fn with_pty(&self, mut action: impl FnMut(Pty)) {
    if !Pty::is_supported() {
      return;
    }

    let cwd = self.build_cwd();
    let args = self.build_args(&cwd);
    let args = args.iter().map(|s| s.as_str()).collect::<Vec<_>>();
    let mut envs = self.build_envs(&cwd);
    if !envs.contains_key("NO_COLOR") {
      // set this by default for pty tests
      envs.insert("NO_COLOR".to_string(), "1".to_string());
    }

    // note(dsherret): for some reason I need to inject the current
    // environment here for the pty tests or else I get dns errors
    if !self.env_clear {
      for (key, value) in std::env::vars() {
        envs.entry(key).or_insert(value);
      }
    }

    let command_path = self.build_command_path();

    self.diagnostic_logger.writeln(format!(
      "command {} {}",
      command_path,
      args.join(" ")
    ));
    self
      .diagnostic_logger
      .writeln(format!("command cwd {}", cwd.display()));
    action(Pty::new(command_path.as_path(), &args, &cwd, Some(envs)))
  }

  pub fn output(&self) -> Result<std::process::Output, std::io::Error> {
    assert!(self.stdin_text.is_none(), "use spawn instead");
    self.build_command().output()
  }

  pub fn status(&self) -> Result<std::process::ExitStatus, std::io::Error> {
    assert!(self.stdin_text.is_none(), "use spawn instead");
    self.build_command().status()
  }

  pub fn spawn(&self) -> Result<DenoChild, std::io::Error> {
    let child = self.build_command().spawn()?;
    let mut child = DenoChild {
      _deno_dir: self.deno_dir.clone(),
      child,
    };

    if let Some(input) = &self.stdin_text {
      let mut p_stdin = child.stdin.take().unwrap();
      write!(p_stdin, "{input}").unwrap();
    }

    Ok(child)
  }

  pub fn spawn_with_piped_output(&self) -> DenoChild {
    self.clone().piped_output().spawn().unwrap()
  }

  pub fn run(&self) -> TestCommandOutput {
    fn read_pipe_to_string(
      mut pipe: os_pipe::PipeReader,
      output_to_stderr: bool,
    ) -> String {
      if output_to_stderr {
        let mut buffer = vec![0; 512];
        let mut final_data = Vec::new();
        loop {
          let size = pipe.read(&mut buffer).unwrap();
          if size == 0 {
            break;
          }
          final_data.extend(&buffer[..size]);
          std::io::stderr().write_all(&buffer[..size]).unwrap();
        }
        String::from_utf8_lossy(&final_data).to_string()
      } else {
        let mut output = String::new();
        pipe.read_to_string(&mut output).unwrap();
        output
      }
    }

    fn sanitize_output(text: String, args: &[OsString]) -> String {
      let mut text = strip_ansi_codes(&text).to_string();
      // deno test's output capturing flushes with a zero-width space in order to
      // synchronize the output pipes. Occasionally this zero width space
      // might end up in the output so strip it from the output comparison here.
      if args.first().and_then(|s| s.to_str()) == Some("test") {
        text = text.replace('\u{200B}', "");
      }
      text
    }

    let mut command = self.build_command();
    let args = command
      .get_args()
      .map(ToOwned::to_owned)
      .collect::<Vec<_>>();
    let (combined_reader, std_out_err_handle) = if self.split_output {
      let (stdout_reader, stdout_writer) = pipe().unwrap();
      let (stderr_reader, stderr_writer) = pipe().unwrap();
      command.stdout(stdout_writer);
      command.stderr(stderr_writer);
      let show_output = self.show_output;
      (
        None,
        Some((
          spawn_thread(move || read_pipe_to_string(stdout_reader, show_output)),
          spawn_thread(move || read_pipe_to_string(stderr_reader, show_output)),
        )),
      )
    } else {
      let (combined_reader, combined_writer) = pipe().unwrap();
      command.stdout(combined_writer.try_clone().unwrap());
      command.stderr(combined_writer);
      (Some(combined_reader), None)
    };

    let mut process = command.spawn().expect("Failed spawning command");

    if let Some(input) = &self.stdin_text {
      let mut p_stdin = process.stdin.take().unwrap();
      write!(p_stdin, "{input}").unwrap();
    }

    // This parent process is still holding its copies of the write ends,
    // and we have to close them before we read, otherwise the read end
    // will never report EOF. The Command object owns the writers now,
    // and dropping it closes them.
    drop(command);

    // Set up a watchdog thread that kills the process if it exceeds the timeout.
    // Uses a channel: dropping the sender signals the watchdog to stop. If
    // recv_timeout hits the deadline first, the process is killed.
    let timeout_handle = self.timeout.map(|timeout| {
      let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>();
      let pid = process.id();
      let handle =
        spawn_thread(move || match cancel_rx.recv_timeout(timeout) {
          Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
            false
          }
          Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
            eprintln!(
              "Test command timed out after {:?}, killing pid {}",
              timeout, pid
            );
            kill_process(pid);
            true
          }
        });
      (cancel_tx, handle)
    });

    let combined = combined_reader.map(|pipe| {
      sanitize_output(read_pipe_to_string(pipe, self.show_output), &args)
    });

    let status = process.wait().unwrap();
    // Drop the sender to cancel the watchdog, then check if it timed out
    if let Some((cancel_tx, handle)) = timeout_handle {
      drop(cancel_tx);
      let timed_out = handle.join().unwrap_or(true);
      if timed_out {
        panic!("Test command timed out");
      }
    }
    let std_out_err = std_out_err_handle.map(|(stdout, stderr)| {
      (
        sanitize_output(stdout.join().unwrap(), &args),
        sanitize_output(stderr.join().unwrap(), &args),
      )
    });
    let exit_code = status.code();
    #[cfg(unix)]
    let signal = {
      use std::os::unix::process::ExitStatusExt;
      status.signal()
    };
    #[cfg(not(unix))]
    let signal = None;

    TestCommandOutput {
      exit_code,
      signal,
      combined,
      std_out_err,
      asserted_exit_code: RefCell::new(false),
      asserted_stdout: RefCell::new(false),
      asserted_stderr: RefCell::new(false),
      asserted_combined: RefCell::new(false),
      diagnostic_logger: self.diagnostic_logger.clone(),
      _deno_dir: self.deno_dir.clone(),
    }
  }

  fn build_command(&self) -> Command {
    let command_path = self.build_command_path();
    let cwd = self.build_cwd();
    let args = self.build_args(&cwd);
    self.diagnostic_logger.writeln(format!(
      "command {} {}",
      command_path,
      args.join(" ")
    ));
    let mut command = Command::new(command_path);
    self
      .diagnostic_logger
      .writeln(format!("command cwd {}", cwd.display()));
    command.current_dir(&cwd);
    if let Some(stdin) = &self.stdin {
      command.stdin(stdin.take());
    }
    if let Some(stdout) = &self.stdout {
      command.stdout(stdout.take());
    }
    if let Some(stderr) = &self.stderr {
      command.stderr(stderr.take());
    }

    command.args(args.iter());
    if self.env_clear {
      command.env_clear();
    }
    let envs = self.build_envs(&cwd);
    command.envs(envs);
    command.stdin(Stdio::piped());
    command
  }

  fn build_command_path(&self) -> PathRef {
    let command_name = if cfg!(windows) && self.command_name == "npm" {
      "npm.cmd"
    } else {
      &self.command_name
    };
    if command_name == "deno" {
      deno_exe_path()
    } else if command_name.starts_with("./") && self.cwd.is_some() {
      self.cwd.as_ref().unwrap().join(command_name)
    } else {
      PathRef::new(PathBuf::from(command_name))
    }
  }

  fn build_args(&self, cwd: &Path) -> Vec<String> {
    if self.args_vec.is_empty() {
      std::borrow::Cow::Owned(
        self
          .args_text
          .split_whitespace()
          .map(|s| s.to_string())
          .collect::<Vec<_>>(),
      )
    } else {
      assert!(
        self.args_text.is_empty(),
        "Do not provide args when providing args_vec."
      );
      std::borrow::Cow::Borrowed(&self.args_vec)
    }
    .iter()
    .map(|arg| self.replace_vars(arg, cwd))
    .collect::<Vec<_>>()
  }

  pub fn build_command_text_for_debugging(&self) -> String {
    fn escape_arg(arg: &str) -> String {
      if arg.is_empty() {
        return "''".to_string();
      }
      if arg.chars().all(|c| {
        c.is_ascii_alphanumeric()
          || c == '-'
          || c == '_'
          || c == '.'
          || c == '/'
          || c == ':'
          || c == '='
      }) {
        arg.to_string()
      } else {
        format!("'{}'", arg.replace('\'', "'\\''"))
      }
    }

    let cwd = self.build_cwd();
    let command_path = self.build_command_path();
    let args = self.build_args(&cwd);

    let mut parts = Vec::new();

    parts.push(command_path.to_string());

    for arg in &args {
      parts.push(escape_arg(arg));
    }

    parts.join(" ")
  }

  fn build_cwd(&self) -> PathBuf {
    self
      .cwd
      .as_ref()
      .map(PathBuf::from)
      .unwrap_or_else(|| std::env::current_dir().unwrap())
  }

  fn build_envs(&self, cwd: &Path) -> HashMap<String, String> {
    let mut envs = self.envs.clone();
    if !envs.contains_key("DENO_DIR") {
      envs.insert("DENO_DIR".to_string(), self.deno_dir.path().to_string());
    }
    if !envs.contains_key("NPM_CONFIG_REGISTRY") {
      envs.insert("NPM_CONFIG_REGISTRY".to_string(), npm_registry_unset_url());
    }
    if !envs.contains_key("DENO_NO_UPDATE_CHECK") {
      envs.insert("DENO_NO_UPDATE_CHECK".to_string(), "1".to_string());
    }
    if !envs.contains_key("JSR_URL") {
      envs.insert("JSR_URL".to_string(), jsr_registry_unset_url());
    }
    if !envs.contains_key("NODEJS_ORG_MIRROR") {
      envs.insert(
        "NODEJS_ORG_MIRROR".to_string(),
        nodejs_org_mirror_unset_url(),
      );
    }
    if !envs.contains_key("PATH") {
      let path = std::env::var_os("PATH").unwrap_or_default();
      let path = std::env::split_paths(&path);
      let additional = deno_exe_path().parent().to_path_buf();
      let path =
        std::env::join_paths(std::iter::once(additional).chain(path)).unwrap();
      envs.insert("PATH".to_string(), path.to_string_lossy().to_string());
    }
    for key in &self.envs_remove {
      envs.remove(key);
    }

    // update any test variables in the env value
    for value in envs.values_mut() {
      *value = self.replace_vars(value, cwd);
    }

    envs
  }

  fn replace_vars(&self, text: &str, cwd: &Path) -> String {
    // todo(dsherret): use monch to extract out the vars
    text
      .replace("$DENO_DIR", &self.deno_dir.path().to_string_lossy())
      .replace("$TESTDATA", &testdata_path().to_string_lossy())
      .replace("$TESTS", &tests_path().to_string_lossy())
      .replace("$PWD", &cwd.to_string_lossy())
  }
}

pub struct DenoChild {
  // keep alive for the duration of the use of this struct
  _deno_dir: TempDir,
  child: Child,
}

impl Deref for DenoChild {
  type Target = Child;
  fn deref(&self) -> &Child {
    &self.child
  }
}

impl DerefMut for DenoChild {
  fn deref_mut(&mut self) -> &mut Child {
    &mut self.child
  }
}

impl DenoChild {
  pub fn wait_with_output_and_timeout(
    self,
    timeout: Duration,
  ) -> Result<std::process::Output, std::io::Error> {
    use std::sync::mpsc;

    let (tx, rx) = mpsc::channel();
    let pid = self.id();

    // Spawn thread to wait for child
    spawn_thread(move || {
      let result = self.wait_with_output();
      let _ = tx.send(result);
    });

    match rx.recv_timeout(timeout) {
      Ok(Ok(output)) => Ok(output),
      Ok(Err(err)) => Err(err),
      Err(err) => {
        _ = crate::process::kill(pid);
        Err(std::io::Error::new(std::io::ErrorKind::TimedOut, err))
      }
    }
  }

  pub fn wait_with_output(
    self,
  ) -> Result<std::process::Output, std::io::Error> {
    self.child.wait_with_output()
  }

  pub fn wait_to_test_result(self, test_name: &str) -> TestResult {
    let mut deno = self;
    let now = Instant::now();
    let stdout = deno.stdout.take().unwrap();
    let no_capture = *file_test_runner::NO_CAPTURE;
    let final_output = Arc::new(Mutex::new(Vec::<String>::new()));
    let stdout = spawn_thread({
      let final_output = final_output.clone();
      let test_name = test_name.to_string();
      move || {
        let reader = BufReader::new(stdout);
        for line in reader.lines() {
          if let Ok(line) = line {
            if no_capture {
              println!(
                "[{test_name} {:0>6.2}] {line}",
                now.elapsed().as_secs_f32()
              );
            } else {
              final_output.lock().push(line);
            }
          } else {
            break;
          }
        }
      }
    });

    let now = Instant::now();
    let stderr = deno.stderr.take().unwrap();
    let stderr = spawn_thread({
      let final_output = final_output.clone();
      let test_name = test_name.to_string();
      move || {
        let reader = BufReader::new(stderr);
        for line in reader.lines() {
          if let Ok(line) = line {
            if no_capture {
              eprintln!(
                "[{test_name} {:0>6.2}] {line}",
                now.elapsed().as_secs_f32()
              );
            } else {
              final_output.lock().push(line);
            }
          } else {
            break;
          }
        }
      }
    });

    const PER_TEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);

    let get_failure_result = |duration: Duration, error_message: String| {
      let mut final_output = std::mem::take(&mut *final_output.lock());
      final_output.push(error_message);
      TestResult::Failed {
        duration: Some(duration),
        output: final_output.join("\n").into_bytes(),
      }
    };

    // keep stdin alive instead of dropping it in wait_with_output
    let _stdin = deno.stdin.take();

    let status = {
      match deno.wait_with_output_and_timeout(PER_TEST_TIMEOUT) {
        Ok(output) => output.status,
        Err(err) => {
          if err.kind() == ErrorKind::TimedOut {
            return get_failure_result(
              now.elapsed(),
              format!("Test {} failed to complete in time", test_name),
            );
          } else {
            panic!("{}", err);
          }
        }
      }
    };
    let duration = now.elapsed();

    #[cfg(unix)]
    if let Some(signal) = std::os::unix::process::ExitStatusExt::signal(&status)
    {
      return get_failure_result(
        duration,
        format!("{:?}\nDeno should not have died with a signal", signal,),
      );
    }
    if status.code() != Some(0) {
      return get_failure_result(
        duration,
        format!(
          "Deno should have exited cleanly (code: {:?})",
          status.code(),
        ),
      );
    }

    stdout.join().unwrap();
    stderr.join().unwrap();

    TestResult::Passed {
      duration: Some(duration),
    }
  }
}

pub struct TestCommandOutput {
  combined: Option<String>,
  std_out_err: Option<(String, String)>,
  exit_code: Option<i32>,
  signal: Option<i32>,
  asserted_stdout: RefCell<bool>,
  asserted_stderr: RefCell<bool>,
  asserted_combined: RefCell<bool>,
  asserted_exit_code: RefCell<bool>,
  diagnostic_logger: DiagnosticLogger,
  // keep alive for the duration of the output reference
  _deno_dir: TempDir,
}

impl Drop for TestCommandOutput {
  // assert the output and exit code was asserted
  fn drop(&mut self) {
    fn panic_unasserted_output(output: &TestCommandOutput, text: &str) {
      output
        .diagnostic_logger
        .writeln(format!("OUTPUT\n{}\nOUTPUT", text));
      panic!(concat!(
        "The non-empty text of the command was not asserted. ",
        "Call `output.skip_output_check()` to skip if necessary.",
      ));
    }

    if std::thread::panicking() {
      return;
    }

    // either the combined output needs to be asserted or both stdout and stderr
    if let Some(combined) = &self.combined
      && !*self.asserted_combined.borrow()
      && !combined.is_empty()
    {
      panic_unasserted_output(self, combined);
    }
    if let Some((stdout, stderr)) = &self.std_out_err {
      if !*self.asserted_stdout.borrow() && !stdout.is_empty() {
        panic_unasserted_output(self, stdout);
      }
      if !*self.asserted_stderr.borrow() && !stderr.is_empty() {
        panic_unasserted_output(self, stderr);
      }
    }

    // now ensure the exit code was asserted
    if !*self.asserted_exit_code.borrow() && self.exit_code != Some(0) {
      self.print_output();
      panic!(
        "The non-zero exit code of the command was not asserted: {:?}",
        self.exit_code,
      )
    }
  }
}

impl TestCommandOutput {
  pub fn skip_output_check(&self) -> &Self {
    *self.asserted_combined.borrow_mut() = true;
    self.skip_stdout_check();
    self.skip_stderr_check();
    self
  }

  pub fn skip_stdout_check(&self) -> &Self {
    *self.asserted_stdout.borrow_mut() = true;
    self
  }

  pub fn skip_stderr_check(&self) -> &Self {
    *self.asserted_stderr.borrow_mut() = true;
    self
  }

  pub fn skip_exit_code_check(&self) -> &Self {
    *self.asserted_exit_code.borrow_mut() = true;
    self
  }

  pub fn exit_code(&self) -> Option<i32> {
    self.skip_exit_code_check();
    self.exit_code
  }

  pub fn signal(&self) -> Option<i32> {
    self.signal
  }

  pub fn combined_output(&self) -> &str {
    self.skip_output_check();
    self
      .combined
      .as_deref()
      .expect("not available since .split_output() was called")
  }

  pub fn stdout(&self) -> &str {
    *self.asserted_stdout.borrow_mut() = true;
    self
      .std_out_err
      .as_ref()
      .map(|(stdout, _)| stdout.as_str())
      .expect("call .split_output() on the builder")
  }

  pub fn stderr(&self) -> &str {
    *self.asserted_stderr.borrow_mut() = true;
    self
      .std_out_err
      .as_ref()
      .map(|(_, stderr)| stderr.as_str())
      .expect("call .split_output() on the builder")
  }

  #[track_caller]
  pub fn assert_exit_code(&self, expected_exit_code: i32) -> &Self {
    let actual_exit_code = self.exit_code();

    if let Some(exit_code) = &actual_exit_code {
      if *exit_code != expected_exit_code {
        self.print_output();
        panic!(
          "bad exit code, expected: {:?}, actual: {:?}",
          expected_exit_code, exit_code,
        );
      }
    } else {
      self.print_output();
      if let Some(signal) = self.signal() {
        panic!(
          "process terminated by signal, expected exit code: {:?}, actual signal: {:?}",
          actual_exit_code, signal,
        );
      } else {
        panic!(
          "process terminated without status code on non unix platform, expected exit code: {:?}",
          actual_exit_code,
        );
      }
    }

    self
  }

  pub fn print_output(&self) {
    if let Some(combined) = &self.combined {
      self
        .diagnostic_logger
        .writeln(format!("OUTPUT\n{combined}\nOUTPUT"));
    } else if let Some((stdout, stderr)) = &self.std_out_err {
      self
        .diagnostic_logger
        .writeln(format!("STDOUT OUTPUT\n{stdout}\nSTDOUT OUTPUT"));
      self
        .diagnostic_logger
        .writeln(format!("STDERR OUTPUT\n{stderr}\nSTDERR OUTPUT"));
    }
  }

  #[track_caller]
  pub fn assert_matches_text(&self, expected_text: impl AsRef<str>) -> &Self {
    self.inner_assert_matches_text(self.combined_output(), expected_text)
  }

  #[track_caller]
  pub fn assert_matches_file(&self, file_path: impl AsRef<Path>) -> &Self {
    self.inner_assert_matches_file(self.combined_output(), file_path)
  }

  #[track_caller]
  pub fn assert_stdout_matches_text(
    &self,
    expected_text: impl AsRef<str>,
  ) -> &Self {
    self.inner_assert_matches_text(self.stdout(), expected_text)
  }

  #[track_caller]
  pub fn assert_stdout_matches_file(
    &self,
    file_path: impl AsRef<Path>,
  ) -> &Self {
    self.inner_assert_matches_file(self.stdout(), file_path)
  }

  #[track_caller]
  pub fn assert_stderr_matches_text(
    &self,
    expected_text: impl AsRef<str>,
  ) -> &Self {
    self.inner_assert_matches_text(self.stderr(), expected_text)
  }

  #[track_caller]
  pub fn assert_stderr_matches_file(
    &self,
    file_path: impl AsRef<Path>,
  ) -> &Self {
    self.inner_assert_matches_file(self.stderr(), file_path)
  }

  #[track_caller]
  fn inner_assert_matches_text(
    &self,
    actual: &str,
    expected: impl AsRef<str>,
  ) -> &Self {
    match &self.diagnostic_logger {
      DiagnosticLogger::Container(logger) => assert_wildcard_match_with_logger(
        actual,
        expected.as_ref(),
        &mut *logger.borrow_mut(),
      ),
      DiagnosticLogger::Null | DiagnosticLogger::Stderr => {
        assert_wildcard_match(actual, expected.as_ref())
      }
    };
    self
  }

  #[track_caller]
  fn inner_assert_matches_file(
    &self,
    actual: &str,
    file_path: impl AsRef<Path>,
  ) -> &Self {
    let output_path = testdata_path().join(file_path);
    self
      .diagnostic_logger
      .writeln(format!("output path {}", output_path));
    let expected_text = output_path.read_to_string();
    self.inner_assert_matches_text(actual, expected_text)
  }
}
