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

use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;

use file_test_runner::RunOptions;
use file_test_runner::TestResult;
use file_test_runner::collection::CollectedCategoryOrTest;
use file_test_runner::collection::CollectedTest;
use file_test_runner::collection::CollectedTestCategory;
use regex::Regex;
use report::CollectedResult;
use report::ErrorInfo;
use serde::Deserialize;
use test_util as util;
use test_util::IS_CI;
use test_util::PathRef;
use test_util::test_runner::FlakyTestTracker;
use test_util::test_runner::Parallelism;
use test_util::test_runner::run_maybe_flaky_test;
use util::tests_path;

mod report;

/// Global counter for generating unique test serial IDs
static TEST_SERIAL_ID: AtomicUsize = AtomicUsize::new(0);

const RUN_ARGS: &[&str] = &["run", "-A", "--quiet", "--unsafe-proto"];

const TEST_ARGS: &[&str] =
  &["test", "-A", "--quiet", "--unsafe-proto", "--no-check"];

/// Per-platform value: either a boolean (true = enabled, false = disabled)
/// or an object describing an expected failure.
///
/// Uses `#[serde(untagged)]` so that config.jsonc can use both
/// `"windows": false` (boolean) and `"windows": { "exitCode": 1 }` (object).
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum PlatformExpectation {
  Enabled(bool),
  ExpectedFailure(ExpectedFailure),
}

/// Describes an expected test failure: the exit code and/or output pattern.
#[derive(Debug, Clone, Default, Deserialize)]
struct ExpectedFailure {
  #[serde(rename = "exitCode")]
  exit_code: Option<i32>,
  output: Option<String>,
}

/// Configuration for a single test from config.jsonc
///
/// Platform fields (`windows`, `darwin`, `linux`) accept:
///   - `false` — skip the test on that OS
///   - `true` (or omit) — run normally; expected to pass
///   - `{ "exitCode": N, "output": "pattern with [WILDCARD]" }` — run the test
///     but expect it to fail with the given exit code / output
///
/// Top-level `exitCode` and `output` apply to **all** platforms unless a
/// platform-specific entry overrides them.
#[derive(Debug, Clone, Default, Deserialize)]
struct TestConfig {
  #[serde(default)]
  flaky: bool,
  /// When `true`, the test is skipped on all platforms. Requires `reason`.
  #[serde(default)]
  ignore: bool,
  windows: Option<PlatformExpectation>,
  darwin: Option<PlatformExpectation>,
  linux: Option<PlatformExpectation>,
  #[serde(rename = "linuxAarch64")]
  linux_aarch64: Option<PlatformExpectation>,
  #[serde(rename = "linuxX86_64")]
  linux_x86_64: Option<PlatformExpectation>,
  reason: Option<String>,
  /// Expected exit code for all platforms (overridden by per-platform config)
  #[serde(rename = "exitCode")]
  exit_code: Option<i32>,
  /// Expected output pattern (with [WILDCARD] support) for all platforms
  output: Option<String>,
  /// Per-test environment variables, layered on top of the runner's defaults.
  /// Used when a single Node test exercises a code path that needs a config
  /// override (e.g. `node_shared_openssl=1`) which would regress other tests
  /// if applied globally.
  env: Option<HashMap<String, String>>,
  /// Extra `deno run`/`deno test` CLI flags to append for this test only,
  /// appearing after the standard `RUN_ARGS`/`TEST_ARGS` and after any
  /// flags derived from the test source's `// Flags:` directive. Use
  /// sparingly - typically for security knobs like
  /// `--unsafely-ignore-certificate-errors` that one specific test
  /// requires and that we explicitly do not want every test to inherit.
  #[serde(rename = "extraDenoArgs", default)]
  extra_deno_args: Vec<String>,
}

/// The full config.json structure
#[derive(Debug, Deserialize)]
struct NodeCompatConfig {
  tests: HashMap<String, TestConfig>,
}

/// Data attached to each collected test
#[derive(Debug, Clone)]
struct NodeCompatTestData {
  test_path: String,
}

fn main() {
  let ci_hash = test_util::hash::check_ci_hash("node_compat", |hasher| {
    let tests = test_util::tests_path();
    hasher
      .hash_dir(tests.join("node_compat"))
      .hash_dir(tests.join("util"))
      .hash_file(test_util::deno_exe_path())
      .hash_file(test_util::test_server_path());
  });
  if matches!(ci_hash, test_util::hash::CiHashStatus::Skip) {
    return;
  }

  let cli_args = parse_cli_args();
  let config = load_config();
  let cli_filter = file_test_runner::collection::parse_cli_arg_filter();
  let mut category = collect_all_tests();

  if let Some(filter) = &cli_filter {
    // With a filter, run any matching test from the full suite
    // e.g. `cargo test --test node_compat -- test-assert`
    category.filter_children(filter);
  } else if !cli_args.report {
    // Without a filter, only run tests listed in config.jsonc
    let config_tests: std::collections::HashSet<&str> =
      config.tests.keys().map(|s| s.as_str()).collect();
    category.children.retain(|child| match child {
      CollectedCategoryOrTest::Test(t) => {
        config_tests.contains(t.data.test_path.as_str())
      }
      _ => true,
    });
  }

  // Apply sharding if CI_SHARD_INDEX / CI_SHARD_TOTAL are set
  let category =
    if let Some(shard) = test_util::test_runner::ShardConfig::from_env() {
      test_util::test_runner::filter_to_shard(category, &shard)
    } else {
      category
    };

  if category.is_empty() {
    return;
  }

  // Partition into sequential and parallel tests
  let (sequential_category, parallel_category) =
    category.partition(|test| test.data.test_path.starts_with("sequential/"));

  let parallelism = Parallelism::default();
  let flaky_test_tracker = Arc::new(FlakyTestTracker::default());

  // Shared state for collecting results
  let results: Arc<Mutex<HashMap<String, CollectedResult>>> =
    Arc::new(Mutex::new(HashMap::new()));

  let reporter = test_util::test_runner::get_test_reporter(
    "node_compat",
    flaky_test_tracker.clone(),
  );

  let config = Arc::new(config);
  let all_tests_flaky = *IS_CI && !cli_args.report;

  // Run sequential tests with parallelism=1
  let summary = file_test_runner::run_tests_summary(
    &sequential_category,
    RunOptions {
      parallelism: file_test_runner::Parallelism::from_usize(1),
      reporter: reporter.clone(),
    },
    {
      let cli_args = cli_args.clone();
      let config = config.clone();
      let flaky_test_tracker = flaky_test_tracker.clone();
      let results = results.clone();
      move |test| {
        let test_config = config.tests.get(&test.data.test_path);
        run_maybe_flaky_test(
          &test.name,
          test_config.is_some_and(|c| c.flaky) || all_tests_flaky,
          &flaky_test_tracker,
          None,
          || run_test(&cli_args, test, test_config, &results),
        )
      }
    },
  );
  if !cli_args.report {
    summary.panic_on_failures();
  }

  // Run parallel tests
  let summary = file_test_runner::run_tests_summary(
    &parallel_category,
    RunOptions {
      parallelism: parallelism.max_parallelism(),
      reporter: reporter.clone(),
    },
    {
      let flaky_test_tracker = flaky_test_tracker.clone();
      let results = results.clone();
      let cli_args = cli_args.clone();
      let config = config.clone();
      move |test| {
        let test_config = config.tests.get(&test.data.test_path);
        run_maybe_flaky_test(
          &test.name,
          test_config.is_some_and(|c| c.flaky) || all_tests_flaky,
          &flaky_test_tracker,
          Some(&parallelism),
          || run_test(&cli_args, test, test_config, &results),
        )
      }
    },
  );

  if !cli_args.report {
    summary.panic_on_failures();
  } else if std::env::var("CI").is_ok() {
    report::generate_report(&results.lock().unwrap());
  }
  if let test_util::hash::CiHashStatus::RunThenCommit(pending) = ci_hash {
    pending.commit();
  }
}

#[derive(Clone)]
struct CliArgs {
  inspect_brk: bool,
  inspect_wait: bool,
  report: bool,
}

// Run with: `cargo test --test node_compat -- <filter>`
// For example: `cargo test --test node_compat -- test-assert`
// Debug with: `cargo test --test node_compat -- test-assert --inspect-brk`
fn parse_cli_args() -> CliArgs {
  let mut inspect_brk = false;
  let mut inspect_wait = false;
  let mut report = false;

  for arg in std::env::args() {
    match arg.as_str() {
      "--inspect-brk" => inspect_brk = true,
      "--inspect-wait" => inspect_wait = true,
      "--report" => report = true,
      _ => {}
    }
  }

  CliArgs {
    inspect_brk,
    inspect_wait,
    report,
  }
}

fn load_config() -> NodeCompatConfig {
  let config_path = tests_path().join("node_compat").join("config.jsonc");
  let config_content = std::fs::read_to_string(&config_path).unwrap();
  jsonc_parser::parse_to_serde_value(&config_content, &Default::default())
    .unwrap()
}

// Directories that don't contain runnable tests
// from https://github.com/denoland/std/pull/2787#discussion_r1001237016
const IGNORED_TEST_DIRS: &[&str] = &[
  "addons",
  "async-hooks",
  "benchmark",
  "cctest",
  "common",
  "doctool",
  "embedding",
  "fixtures",
  "fuzzers",
  "js-native-api",
  "known_issues",
  "node-api",
  "overlapped-checker",
  "report",
  "testpy",
  "tick-processor",
  "tools",
  "v8-updates",
  "wpt",
];

/// Collect all test files from the suite directory.
fn collect_all_tests() -> CollectedTestCategory<NodeCompatTestData> {
  ensure_dir_fixtures();
  let suite_dir = suite_test_dir();
  let mut children = Vec::new();

  // Scan all subdirectories in the test suite
  for subdir_entry in std::fs::read_dir(&suite_dir).unwrap().flatten() {
    let subdir_name = match subdir_entry.file_name().to_str() {
      Some(name) => name.to_string(),
      None => continue,
    };

    // Skip hidden directories (includes .tmp*)
    if subdir_name.starts_with('.') {
      continue;
    }

    // Skip directories that don't contain runnable tests
    if IGNORED_TEST_DIRS.contains(&subdir_name.as_str()) {
      continue;
    }

    if !subdir_entry.file_type().is_ok_and(|t| t.is_dir()) {
      continue;
    }

    // Recursively collect test files from this subdirectory
    collect_test_files_recursive(
      &subdir_entry.path(),
      &subdir_name,
      &mut children,
    );
  }

  wrap_in_category(children)
}

fn collect_test_files_recursive(
  dir: &std::path::Path,
  relative_prefix: &str,
  children: &mut Vec<CollectedCategoryOrTest<NodeCompatTestData>>,
) {
  for entry in std::fs::read_dir(dir).unwrap().flatten() {
    let file_name = match entry.file_name().to_str() {
      Some(name) => name.to_string(),
      None => continue,
    };

    // Skip hidden files/directories
    if file_name.starts_with('.') {
      continue;
    }

    let file_type = match entry.file_type() {
      Ok(ft) => ft,
      Err(_) => continue,
    };

    if file_type.is_dir() {
      // Recurse into subdirectory
      let new_prefix = format!("{}/{}", relative_prefix, file_name);
      collect_test_files_recursive(&entry.path(), &new_prefix, children);
    } else {
      // Only include test-*.{js,mjs,cjs,ts} files
      if !file_name.starts_with("test-") {
        continue;
      }
      if !file_name.ends_with(".js")
        && !file_name.ends_with(".mjs")
        && !file_name.ends_with(".cjs")
        && !file_name.ends_with(".ts")
      {
        continue;
      }

      let test_name = format!("{}/{}", relative_prefix, file_name);
      children.push(create_collected_test(&test_name));
    }
  }
}

fn suite_test_dir() -> std::path::PathBuf {
  tests_path()
    .join("node_compat/runner/suite/test")
    .to_path_buf()
}

/// Restore directory-only fixtures that git can't track.
///
/// The vendored Node.js suite contains a few empty directories that the
/// individual tests rely on (e.g. `test/fixtures/wasi/subdir` is asserted
/// by `test-wasi-readdir.js`). Git does not preserve empty directories on
/// checkout, so we recreate them here before the runner enumerates tests.
fn ensure_dir_fixtures() {
  let suite = suite_test_dir();
  // Extend this when more directory-only fixtures are needed.
  let p = suite.join("fixtures/wasi/subdir");
  if !p.exists() {
    let _ = std::fs::create_dir_all(&p);
  }
}

fn create_collected_test(
  test_name: &str,
) -> CollectedCategoryOrTest<NodeCompatTestData> {
  let test_file_path = suite_test_dir().join(test_name);
  let full_name = format!("node_compat::{}", test_name.replace('/', "::"));

  CollectedCategoryOrTest::Test(CollectedTest {
    name: full_name,
    path: test_file_path,
    line_and_column: None,
    data: NodeCompatTestData {
      test_path: test_name.to_string(),
    },
  })
}

fn wrap_in_category(
  children: Vec<CollectedCategoryOrTest<NodeCompatTestData>>,
) -> CollectedTestCategory<NodeCompatTestData> {
  CollectedTestCategory {
    name: "node_compat".to_string(),
    path: tests_path().join("node_compat").to_path_buf(),
    children,
  }
}

fn platform_expectation(config: &TestConfig) -> Option<&PlatformExpectation> {
  let os = std::env::consts::OS;
  let arch = std::env::consts::ARCH;
  match (os, arch) {
    ("linux", "aarch64") => {
      config.linux_aarch64.as_ref().or(config.linux.as_ref())
    }
    ("linux", "x86_64") => {
      config.linux_x86_64.as_ref().or(config.linux.as_ref())
    }
    _ => match os {
      "windows" => config.windows.as_ref(),
      "linux" => config.linux.as_ref(),
      "macos" => config.darwin.as_ref(),
      _ => None,
    },
  }
}

fn should_ignore(config: &TestConfig) -> Option<&str> {
  if config.ignore {
    return Some(
      config
        .reason
        .as_deref()
        .expect("tests with `ignore: true` must have a `reason`"),
    );
  }
  if let Some(PlatformExpectation::Enabled(false)) =
    platform_expectation(config)
  {
    let os = std::env::consts::OS;
    return Some(config.reason.as_deref().unwrap_or(match os {
      "windows" => "disabled on windows",
      "linux" => "disabled on linux",
      "macos" => "disabled on macos",
      _ => "disabled on this platform",
    }));
  }
  None
}

/// Resolve the expected-failure expectation for the current platform.
///
/// Returns `Some(ExpectedFailure)` if the test is expected to fail
/// (either from a platform-specific object or from top-level fields),
/// or `None` if the test is expected to pass.
fn resolve_expected_failure(config: &TestConfig) -> Option<ExpectedFailure> {
  // 1. Platform-specific object takes precedence
  if let Some(PlatformExpectation::ExpectedFailure(ef)) =
    platform_expectation(config)
  {
    return Some(ef.clone());
  }

  // 2. Fall back to top-level exitCode / output
  if config.exit_code.is_some() || config.output.is_some() {
    return Some(ExpectedFailure {
      exit_code: config.exit_code,
      output: config.output.clone(),
    });
  }

  None
}

fn uses_node_test_module(source: &str) -> bool {
  source.contains("'node:test'") || source.contains("\"node:test\"")
}

fn parse_flags(source: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
  let mut v8_flags = Vec::new();
  let mut node_options = Vec::new();
  let mut deno_args = Vec::new();

  let re = Regex::new(r"^// Flags: (.+)$").unwrap();
  for line in source.lines() {
    if let Some(captures) = re.captures(line) {
      let flags_str = captures.get(1).unwrap().as_str();
      for flag in flags_str.split_whitespace() {
        // V8 treats `--foo-bar` and `--foo_bar` as the same flag, and Node's
        // test suite uses both spellings interchangeably. Normalize underscores
        // to hyphens before matching so we don't have to enumerate both forms.
        let normalized = if flag.starts_with("--") {
          flag.replace('_', "-")
        } else {
          flag.to_string()
        };
        match normalized.as_str() {
          "--expose-externalize-string" => {
            v8_flags.push("--expose-externalize-string".to_string());
          }
          "--expose-gc" => {
            v8_flags.push("--expose-gc".to_string());
          }
          "--no-concurrent-array-buffer-sweeping" => {
            v8_flags.push("--no-concurrent-array-buffer-sweeping".to_string());
          }
          "--no-warnings" => {
            node_options.push("--no-warnings".to_string());
          }
          "--pending-deprecation" => {
            node_options.push("--pending-deprecation".to_string());
          }
          "--expose-internals" | "--expose_internals" => {
            node_options.push("--expose-internals".to_string());
          }
          "--tls-min-v1.0"
          | "--tls-min-v1.1"
          | "--tls-min-v1.2"
          | "--tls-min-v1.3"
          | "--tls-max-v1.2"
          | "--tls-max-v1.3"
          | "--use-bundled-ca"
          | "--no-use-bundled-ca"
          | "--use-openssl-ca"
          | "--no-use-openssl-ca"
          | "--use-system-ca"
          | "--no-use-system-ca" => {
            node_options.push(flag.to_string());
          }
          n if n.starts_with("--dns-result-order=") => {
            node_options.push(flag.to_string());
          }
          "--allow-natives-syntax" => {
            v8_flags.push("--allow-natives-syntax".to_string());
          }
          n if n.starts_with("--title=") => {
            node_options.push(flag.to_string());
          }
          // Inspector tests opt in to the inspector via `--inspect=PORT`
          // (commonly `--inspect=0` to pick a random port). Forward to
          // Deno, normalizing the bare port form to `127.0.0.1:PORT` since
          // Deno's CLI expects `host:port`.
          n if n == "--inspect" || n.starts_with("--inspect=") => {
            let mapped = match n.strip_prefix("--inspect=") {
              None | Some("") => "--inspect=127.0.0.1:0".to_string(),
              Some(rest) if rest.chars().all(|c| c.is_ascii_digit()) => {
                format!("--inspect=127.0.0.1:{}", rest)
              }
              Some(rest) => format!("--inspect={}", rest),
            };
            deno_args.push(mapped);
          }
          "--experimental-network-inspection" => {
            // Deno emits Network.* events when the inspector is attached;
            // no separate opt-in flag.
          }
          _ => {}
        }
      }
      break; // Only process the first Flags: line
    }
  }

  (v8_flags, node_options, deno_args)
}

fn truncate_output(output: &str, max_len: usize) -> String {
  if output.len() > max_len {
    format!("{} ...", &output[..output.floor_char_boundary(max_len)])
  } else {
    output.to_string()
  }
}

/// Common setup for running a node compat test (shared between PTY and piped paths).
struct TestSetup {
  test_suite_path: PathRef,
  uses_node_test: bool,
  args: Vec<String>,
  env_vars: HashMap<String, String>,
  timeout: Duration,
}

impl TestSetup {
  fn new(
    cli_args: &CliArgs,
    data: &NodeCompatTestData,
    test_config: Option<&TestConfig>,
  ) -> Self {
    let test_suite_path = tests_path().join("node_compat/runner/suite");
    let test_path = format!("test/{}", data.test_path);
    let full_test_path = test_suite_path.join(&test_path);

    let source = full_test_path.read_to_string();
    let uses_node_test = uses_node_test_module(&source);
    let (v8_flags, node_options, extra_deno_args) = parse_flags(&source);

    let mut args: Vec<String> = if uses_node_test {
      TEST_ARGS.iter().map(|s| s.to_string()).collect()
    } else {
      RUN_ARGS.iter().map(|s| s.to_string()).collect()
    };

    if !v8_flags.is_empty() {
      args.push(format!("--v8-flags={}", v8_flags.join(",")));
    }
    for arg in &extra_deno_args {
      args.push(arg.clone());
    }
    // Per-test extra args from config.jsonc, e.g. security knobs that
    // only one specific test needs.
    if let Some(extra) = test_config {
      for arg in &extra.extra_deno_args {
        args.push(arg.clone());
      }
    }
    if cli_args.inspect_brk {
      args.push("--inspect-brk".to_string());
    }
    if cli_args.inspect_wait {
      args.push("--inspect-wait".to_string());
    }
    args.push(test_path.clone());

    let serial_id = TEST_SERIAL_ID.fetch_add(1, Ordering::SeqCst);

    let mut env_vars = HashMap::new();
    env_vars.insert("NODE_TEST_KNOWN_GLOBALS".to_string(), "0".to_string());
    env_vars.insert("NODE_SKIP_FLAG_CHECK".to_string(), "1".to_string());
    env_vars.insert("NODE_OPTIONS".to_string(), node_options.join(" "));
    env_vars.insert("NO_COLOR".to_string(), "1".to_string());
    env_vars.insert("TEST_SERIAL_ID".to_string(), serial_id.to_string());

    // Per-test env overrides win over the defaults above.
    if let Some(extra) = test_config.and_then(|c| c.env.as_ref()) {
      for (key, value) in extra {
        env_vars.insert(key.clone(), value.clone());
      }
    }

    let timeout = Duration::from_millis(if cfg!(target_os = "macos") {
      20_000
    } else {
      10_000
    });

    TestSetup {
      test_suite_path,
      uses_node_test,
      args,
      env_vars,
      timeout,
    }
  }

  fn run_piped(&self) -> (bool, String, Option<i32>) {
    let mut cmd = util::deno_cmd().disable_diagnostic_logging();
    cmd = cmd.current_dir(&self.test_suite_path);
    for arg in &self.args {
      cmd = cmd.arg(arg);
    }
    for (key, value) in &self.env_vars {
      cmd = cmd.env(key, value);
    }

    let child = cmd.piped_output().spawn().unwrap();
    match child.wait_with_output_and_timeout(self.timeout) {
      Ok(output) => {
        let success = output.status.success();
        let exit_code = output.status.code();
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        let output_str = format!("{}\n{}", stdout, stderr);
        (success, output_str, exit_code)
      }
      Err(_) => {
        let output_str =
          format!("Test timed out after {}ms", self.timeout.as_millis());
        (false, output_str, None)
      }
    }
  }

  fn run_pty(&self) -> (bool, String, Option<i32>) {
    let deno_exe = util::deno_exe_path();
    let args: Vec<&str> = self.args.iter().map(|s| s.as_str()).collect();

    // PTY needs the full environment (inherit + our overrides)
    let mut env_vars = self.env_vars.clone();
    for (key, value) in std::env::vars() {
      env_vars.entry(key).or_insert(value);
    }

    let pty_output = util::pty::run_in_pty(
      deno_exe.as_path(),
      &args,
      self.test_suite_path.as_path(),
      Some(env_vars),
      self.timeout,
    );

    let output_text = String::from_utf8_lossy(&pty_output.output).to_string();
    let success = pty_output.exit_code == Some(0);
    (success, output_text, pty_output.exit_code)
  }

  fn debugging_command_text(&self) -> String {
    let node_options = self
      .env_vars
      .get("NODE_OPTIONS")
      .map(|s| s.as_str())
      .unwrap_or("");
    let args_str = self
      .args
      .iter()
      .map(|a| {
        if a.contains(' ') {
          format!("'{}'", a)
        } else {
          a.clone()
        }
      })
      .collect::<Vec<_>>()
      .join(" ");
    // Surface any test-specific env vars (set via TestConfig.env) so the
    // copy-pasted command reproduces the failure faithfully.
    let well_known: &[&str] = &[
      "NODE_TEST_KNOWN_GLOBALS",
      "NODE_SKIP_FLAG_CHECK",
      "NODE_OPTIONS",
      "NO_COLOR",
      "TEST_SERIAL_ID",
    ];
    let mut extras = self
      .env_vars
      .iter()
      .filter(|(k, _)| !well_known.contains(&k.as_str()))
      .map(|(k, v)| format!("{}='{}'", k, v.replace("'", "\\'")))
      .collect::<Vec<_>>();
    extras.sort();
    let extras_str = if extras.is_empty() {
      String::new()
    } else {
      format!("{} ", extras.join(" "))
    };
    format!(
      "Command: {}",
      deno_terminal::colors::gray(format!(
        "{}NODE_TEST_KNOWN_GLOBALS=0 NODE_SKIP_FLAG_CHECK=1 NODE_OPTIONS='{}' deno {}",
        extras_str,
        node_options.replace("'", "\\'"),
        args_str,
      ))
    )
  }
}

fn make_collected_result(
  success: bool,
  output_text: &str,
  uses_node_test: bool,
  timeout: Duration,
  timed_out: bool,
  exit_code: Option<i32>,
) -> CollectedResult {
  if success {
    CollectedResult {
      passed: Some(true),
      error: None,
      uses_node_test,
      ignore_reason: None,
    }
  } else {
    CollectedResult {
      passed: Some(false),
      error: Some(ErrorInfo {
        code: exit_code,
        stderr: if output_text.is_empty() {
          None
        } else {
          Some(truncate_output(output_text, 2000))
        },
        timeout: if timed_out {
          Some(timeout.as_millis() as u64)
        } else {
          None
        },
        message: None,
      }),
      uses_node_test,
      ignore_reason: None,
    }
  }
}

fn run_test(
  cli_args: &CliArgs,
  test: &CollectedTest<NodeCompatTestData>,
  test_config: Option<&TestConfig>,
  results: &Arc<Mutex<HashMap<String, CollectedResult>>>,
) -> TestResult {
  let data = &test.data;

  // Check platform-specific ignores
  if let Some(reason) = test_config.and_then(|c| should_ignore(c)) {
    results.lock().unwrap().insert(
      data.test_path.clone(),
      CollectedResult {
        passed: None,
        error: None,
        uses_node_test: false,
        ignore_reason: Some(reason.to_string()),
      },
    );
    return TestResult::Ignored;
  }

  // Skip pseudo-tty tests when PTY is not supported (e.g., Windows CI)
  let is_pseudo_tty_test = data.test_path.starts_with("pseudo-tty/");
  if is_pseudo_tty_test && !util::pty::Pty::is_supported() {
    results.lock().unwrap().insert(
      data.test_path.clone(),
      CollectedResult {
        passed: None,
        error: None,
        uses_node_test: false,
        ignore_reason: Some("PTY not supported on this platform".to_string()),
      },
    );
    return TestResult::Ignored;
  }

  let setup = TestSetup::new(cli_args, data, test_config);

  let (success, output_text, exit_code) = if is_pseudo_tty_test {
    setup.run_pty()
  } else {
    setup.run_piped()
  };

  let debugging_command_text = setup.debugging_command_text();

  // Check for expected failure configuration
  if let Some(ef) = test_config.and_then(resolve_expected_failure) {
    return handle_expected_failure(
      &ef,
      success,
      exit_code,
      setup.uses_node_test,
      &output_text,
      &debugging_command_text,
      &data.test_path,
      results,
    );
  }

  let collected = make_collected_result(
    success,
    &output_text,
    setup.uses_node_test,
    setup.timeout,
    !success && output_text.starts_with("Test timed out"),
    exit_code,
  );

  results
    .lock()
    .unwrap()
    .insert(data.test_path.clone(), collected);

  if success {
    if *file_test_runner::NO_CAPTURE {
      test_util::eprintln!("{}", debugging_command_text);
    }
    TestResult::Passed { duration: None }
  } else {
    TestResult::Failed {
      duration: None,
      output: format!("{}\n{}", output_text, debugging_command_text)
        .into_bytes(),
    }
  }
}

/// Handle a test that has an expected-failure configuration.
///
/// Returns `Passed` when the test fails in the expected way (matching exit code
/// and/or output pattern), and `Failed` otherwise.
#[allow(clippy::too_many_arguments, reason = "all arguments are needed")]
fn handle_expected_failure(
  ef: &ExpectedFailure,
  success: bool,
  actual_exit_code: Option<i32>,
  uses_node_test: bool,
  output_text: &str,
  debugging_command_text: &str,
  test_path: &str,
  results: &Arc<Mutex<HashMap<String, CollectedResult>>>,
) -> TestResult {
  if success {
    // Test passed but was expected to fail
    results.lock().unwrap().insert(
      test_path.to_string(),
      CollectedResult {
        passed: Some(false),
        error: Some(ErrorInfo {
          code: actual_exit_code,
          stderr: None,
          timeout: None,
          message: Some("expected test to fail but it passed".to_string()),
        }),
        uses_node_test,
        ignore_reason: None,
      },
    );
    return TestResult::Failed {
      duration: None,
      output: format!(
        "Test was expected to fail but passed\n{}",
        debugging_command_text
      )
      .into_bytes(),
    };
  }

  // When exitCode/output are omitted, any value is accepted.
  let exit_code_matches =
    ef.exit_code.is_none_or(|ec| actual_exit_code == Some(ec));
  let output_matches = ef.output.as_ref().is_none_or(|pattern| {
    util::wildcard_match_detailed(pattern, output_text).is_success()
  });

  if exit_code_matches && output_matches {
    // Failed as expected — treat as a pass
    results.lock().unwrap().insert(
      test_path.to_string(),
      CollectedResult {
        passed: Some(true),
        error: None,
        uses_node_test,
        ignore_reason: None,
      },
    );
    if *file_test_runner::NO_CAPTURE {
      test_util::eprintln!("{}", debugging_command_text);
    }
    return TestResult::Passed { duration: None };
  }

  // Failed, but not in the expected way
  let mut mismatch_details = String::new();
  if !exit_code_matches {
    mismatch_details.push_str(&format!(
      "Exit code mismatch: expected {:?}, got {:?}\n",
      ef.exit_code, actual_exit_code
    ));
  }
  if !output_matches {
    let match_result = ef
      .output
      .as_ref()
      .map(|pattern| util::wildcard_match_detailed(pattern, output_text));
    if let Some(util::WildcardMatchResult::Fail(detail)) = match_result {
      mismatch_details
        .push_str(&format!("Output mismatch detail:\n{}\n", detail));
    } else {
      mismatch_details.push_str("Output did not match expected pattern\n");
    }
  }

  results.lock().unwrap().insert(
    test_path.to_string(),
    CollectedResult {
      passed: Some(false),
      error: Some(ErrorInfo {
        code: actual_exit_code,
        stderr: Some(truncate_output(output_text, 2000)),
        timeout: None,
        message: Some(mismatch_details.clone()),
      }),
      uses_node_test,
      ignore_reason: None,
    },
  );
  TestResult::Failed {
    duration: None,
    output: format!(
      "Test failed but not in the expected way:\n{}\nActual output:\n{}\n{}",
      mismatch_details, output_text, debugging_command_text
    )
    .into_bytes(),
  }
}
