scripts/parser-tests/utils/parser-test-runner.js

Summary

Maintainability
B
4 hrs
Test Coverage
"use strict";

const fs = require("fs").promises;
const chalk = require("chalk");
const { parse: parser } = require("../../../packages/babel-parser");

const dot = chalk.gray(".");

class TestRunner {
  constructor({
    testDir,
    allowlist,
    logInterval = 1,
    shouldUpdate,
    getTests,
    parse = this.parse,
  }) {
    this.testDir = testDir;
    this.allowlist = allowlist;
    this.logInterval = logInterval;
    this.shouldUpdate = shouldUpdate;
    this.getTests = getTests;
    this.parse = parse;
  }

  async run() {
    const allowlistP = this.getAllowlist();

    console.log(`Now running tests...`);

    const results = [];
    for await (const result of this.runTests()) {
      results.push(result);
      if (results.length % this.logInterval === 0) process.stdout.write(dot);
    }
    process.stdout.write("\n");

    const summary = this.interpret(results, await allowlistP);

    await this.output(summary);
  }

  async *runTests() {
    for await (const test of this.getTests()) {
      yield this.runTest(test);
    }
  }

  runTest(test) {
    try {
      this.parse(test, parser);
      test.actualError = false;
    } catch (err) {
      test.actualError = true;
    }

    test.result = test.expectedError !== test.actualError ? "fail" : "pass";

    return test;
  }

  parse(test, parser) {
    parser(test.contents, {
      sourceType: test.sourceType,
      plugins: test.plugins,
    });
  }

  async getAllowlist() {
    const contents = await fs.readFile(this.allowlist, "utf-8");
    const table = new Set();

    for (const line of contents.split("\n")) {
      const filename = line.replace(/#.*$/, "").trim();
      if (filename) table.add(filename);
    }

    return table;
  }

  async updateAllowlist(summary) {
    const contents = await fs.readFile(this.allowlist, "utf-8");

    const toRemove = summary.disallowed.success
      .concat(summary.disallowed.failure)
      .map(test => test.id)
      .concat(summary.unrecognized);

    const updated = summary.disallowed.falsePositive
      .concat(summary.disallowed.falseNegative)
      .map(test => test.id);

    for (const line of contents.split("\n")) {
      const testId = line.replace(/#.*$/, "").trim();
      if (!toRemove.includes(testId) && line) {
        updated.push(line);
      }
    }

    updated.sort();

    await fs.writeFile(this.allowlist, updated.join("\n") + "\n", "utf8");
  }

  interpret(results, allowlist) {
    const summary = {
      passed: true,
      allowed: {
        success: [],
        failure: [],
        falsePositive: [],
        falseNegative: [],
      },
      disallowed: {
        success: [],
        failure: [],
        falsePositive: [],
        falseNegative: [],
      },
      unrecognized: null,
      count: results.length,
    };

    results.forEach(function (result) {
      let classification, isAllowed;
      const inAllowlist = allowlist.has(result.id);
      allowlist.delete(result.id);

      if (!result.expectedError) {
        if (!result.actualError) {
          classification = "success";
          isAllowed = !inAllowlist;
        } else {
          classification = "falseNegative";
          isAllowed = inAllowlist;
        }
      } else {
        if (!result.actualError) {
          classification = "falsePositive";
          isAllowed = inAllowlist;
        } else {
          classification = "failure";
          isAllowed = !inAllowlist;
        }
      }

      summary.passed &= isAllowed;
      summary[isAllowed ? "allowed" : "disallowed"][classification].push(
        result
      );
    });

    summary.unrecognized = Array.from(allowlist);
    summary.passed = !!summary.passed && summary.unrecognized.length === 0;

    return summary;
  }

  async output(summary) {
    const goodnews = [
      summary.allowed.success.length + " valid programs parsed without error",
      summary.allowed.failure.length +
        " invalid programs produced a parsing error",
      summary.allowed.falsePositive.length +
        " invalid programs did not produce a parsing error" +
        " (and allowed by the allowlist file)",
      summary.allowed.falseNegative.length +
        " valid programs produced a parsing error" +
        " (and allowed by the allowlist file)",
    ];
    const badnews = [];
    const badnewsDetails = [];

    void [
      {
        tests: summary.disallowed.success,
        label:
          "valid programs parsed without error" +
          " (in violation of the allowlist file)",
      },
      {
        tests: summary.disallowed.failure,
        label:
          "invalid programs produced a parsing error" +
          " (in violation of the allowlist file)",
      },
      {
        tests: summary.disallowed.falsePositive,
        label:
          "invalid programs did not produce a parsing error" +
          " (without a corresponding entry in the allowlist file)",
      },
      {
        tests: summary.disallowed.falseNegative,
        label:
          "valid programs produced a parsing error" +
          " (without a corresponding entry in the allowlist file)",
      },
      {
        tests: summary.unrecognized,
        label: "non-existent programs specified in the allowlist file",
      },
    ].forEach(function ({ tests, label }) {
      if (!tests.length) {
        return;
      }

      const desc = tests.length + " " + label;

      badnews.push(desc);
      badnewsDetails.push(desc + ":");
      badnewsDetails.push(...tests.map(test => `  ${test.id || test}`));
    });

    console.log(`Testing complete (${summary.count} tests).`);
    console.log("Summary:");
    console.log(chalk.green(goodnews.join("\n").replace(/^/gm, " ✔ ")));

    if (!summary.passed) {
      console.log("");
      console.log(chalk.red(badnews.join("\n").replace(/^/gm, " ✘ ")));
      console.log("");
      console.log("Details:");
      console.log(badnewsDetails.join("\n").replace(/^/gm, "   "));
    }

    if (this.shouldUpdate) {
      await this.updateAllowlist(summary);
      console.log("");
      console.log("Allowlist file updated.");
    } else {
      process.exitCode = summary.passed ? 0 : 1;
    }
  }
}

module.exports = exports = TestRunner;