// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.

// deno-lint-ignore-file no-console

/**
 * This script will run the test files specified in the configuration file.
 *
 * Each test file will be run independently (in a separate process as this is
 * what Node.js is doing) and we wait until it completes. If the process reports
 * an abnormal code, the test is reported and the test suite will fail
 * immediately.
 *
 * Some tests check for presence of certain `process.exitCode`.
 * Some tests depends on directories/files created by other tests - they must
 * all share the same working directory.
 */

import { magenta } from "@std/fmt/colors";
import { pooledMap } from "@std/async/pool";
import { dirname, fromFileUrl, join } from "@std/path";
import { assertEquals, fail } from "@std/assert";
import { distinct } from "@std/collections";
import {
  config,
  getPathsFromTestSuites,
  partitionParallelTestPaths,
} from "./common.ts";

// If the test case is invoked like
// deno test -A tests/node_compat/test.ts -- <test-names>
// Use the <test-names> as filters
const filters = Deno.args;
const hasFilters = filters.length > 0;
const toolsPath = dirname(fromFileUrl(import.meta.url));
const testPaths = partitionParallelTestPaths(
  getPathsFromTestSuites(config.tests).concat(
    getPathsFromTestSuites(config.ignore),
  ),
);
testPaths.sequential = distinct(testPaths.sequential);
testPaths.parallel = distinct(testPaths.parallel);

const cwd = new URL(".", import.meta.url);
const windowsIgnorePaths = new Set(
  getPathsFromTestSuites(config.windowsIgnore),
);
const darwinIgnorePaths = new Set(
  getPathsFromTestSuites(config.darwinIgnore),
);

const decoder = new TextDecoder();
let testSerialId = 0;

function parseFlags(source: string): string[] {
  const line = /^\/\/ Flags: (.+)$/um.exec(source);
  if (line == null) return [];
  return line[1].split(" ");
}

async function runTest(t: Deno.TestContext, path: string): Promise<void> {
  // If filter patterns are given and any pattern doesn't match
  // to the file path, then skip the case
  if (
    filters.length > 0 &&
    filters.every((pattern) => !path.includes(pattern))
  ) {
    return;
  }
  const ignore =
    (Deno.build.os === "windows" && windowsIgnorePaths.has(path)) ||
    (Deno.build.os === "darwin" && darwinIgnorePaths.has(path));
  await t.step({
    name: `Node.js compatibility "${path}"`,
    ignore,
    sanitizeOps: false,
    sanitizeResources: false,
    sanitizeExit: false,
    fn: async () => {
      const testCase = join(toolsPath, "test", path);

      const v8Flags = ["--stack-size=4000"];
      const testSource = await Deno.readTextFile(testCase);
      const envVars: Record<string, string> = {};
      const knownGlobals: string[] = [];
      parseFlags(testSource).forEach((flag) => {
        switch (flag) {
          case "--expose_externalize_string":
            v8Flags.push("--expose-externalize-string");
            knownGlobals.push("createExternalizableString");
            break;
          case "--expose-gc":
            v8Flags.push("--expose-gc");
            knownGlobals.push("gc");
            break;
          default:
            break;
        }
      });
      if (knownGlobals.length > 0) {
        envVars["NODE_TEST_KNOWN_GLOBALS"] = knownGlobals.join(",");
      }
      // TODO(nathanwhit): once we match node's behavior on executing
      // `node:test` tests when we run a file, we can remove this
      const usesNodeTest = testSource.includes("node:test");
      const args = [
        usesNodeTest ? "test" : "run",
        "-A",
        "--quiet",
        //"--unsafely-ignore-certificate-errors",
        "--unstable-unsafe-proto",
        "--unstable-bare-node-builtins",
        "--unstable-fs",
        "--v8-flags=" + v8Flags.join(),
      ];
      if (usesNodeTest) {
        // deno test typechecks by default + we want to pass script args
        args.push("--no-check", "runner.ts", "--", testCase);
      } else {
        args.push("runner.ts", testCase);
      }

      // Pipe stdout in order to output each test result as Deno.test output
      // That way the tests will respect the `--quiet` option when provided
      const command = new Deno.Command(Deno.execPath(), {
        args,
        env: {
          TEST_SERIAL_ID: String(testSerialId++),
          ...envVars,
        },
        cwd,
        stdout: "piped",
        stderr: "piped",
      }).spawn();
      const warner = setTimeout(() => {
        console.error(`Test is running slow: ${testCase}`);
      }, 2 * 60_000);
      const killer = setTimeout(() => {
        console.error(
          `Test ran far too long, terminating with extreme prejudice: ${testCase}`,
        );
        command.kill();
      }, 10 * 60_000);
      const { code, stdout, stderr } = await command.output();
      clearTimeout(warner);
      clearTimeout(killer);

      if (code !== 0) {
        // If the test case failed, show the stdout, stderr, and instruction
        // for repeating the single test case.
        if (stdout.length) {
          console.log(decoder.decode(stdout));
        }
        const stderrOutput = decoder.decode(stderr);
        const repeatCmd = magenta(
          `./target/debug/deno test --config tests/config/deno.json -A tests/node_compat/test.ts -- ${path}`,
        );
        const msg = `"${magenta(path)}" failed:

${stderrOutput}

You can repeat only this test with the command:

  ${repeatCmd}
`;
        console.log(msg);
        fail(msg);
      } else if (hasFilters) {
        // Even if the test case is successful, shows the stdout and stderr
        // when test case filtering is specified.
        if (stdout.length) console.log(decoder.decode(stdout));
        if (stderr.length) console.log(decoder.decode(stderr));
      }
    },
  });
}

Deno.test("Node.js compatibility", async (t) => {
  for (const path of testPaths.sequential) {
    await runTest(t, path);
  }
  const testPool = pooledMap(
    navigator.hardwareConcurrency,
    testPaths.parallel,
    (path) => runTest(t, path),
  );
  const testCases = [];
  for await (const testCase of testPool) {
    testCases.push(testCase);
  }
  await Promise.all(testCases);
});

function checkConfigTestFilesOrder(testFileLists: Array<string[]>) {
  for (const testFileList of testFileLists) {
    const sortedTestList = JSON.parse(JSON.stringify(testFileList));
    sortedTestList.sort((a: string, b: string) =>
      a.toLowerCase().localeCompare(b.toLowerCase())
    );
    assertEquals(
      testFileList,
      sortedTestList,
      "File names in `config.json` are not correct order.",
    );
  }
}

if (!hasFilters) {
  Deno.test("checkConfigTestFilesOrder", function () {
    checkConfigTestFilesOrder([
      ...Object.keys(config.ignore).map((suite) => config.ignore[suite]),
      ...Object.keys(config.tests).map((suite) => config.tests[suite]),
    ]);
  });
}