Skip to content

Commit

Permalink
Add test script to try Sucrase against Babel test cases (#720)
Browse files Browse the repository at this point in the history
As I was porting Babel changes and reworking bits of the parser, I was worried
that I might introduce regressions in various language corner cases, so it
seemed like a good idea to extend the test suite to more proactively find known
tricky syntax cases. The Babel test suite itself has many fixtures showing off
various syntax cases, so this PR adds a script that clones the Babel repo and
tries Sucrase on every babel-parser test case where the parse is expected to
pass. If Sucrase fails, we try Babel with a Sucrase-like configuration, since
Babel tests somtimes assume sloppy mode or experimental features that Sucrase
doesn't support. For now, the test runner is just its own file rather than using
a real test framework, but we run it in CI to avoid regressions on future code
changes.

This also refactors the directory structure to create a "spec compliance tests"
directory that includes this new one and test262. In the future, it might have
a similar runner for the TypeScript test suite and possibly other test suites.
  • Loading branch information
alangpierce authored Jul 13, 2022
1 parent d85cdda commit d304d24
Show file tree
Hide file tree
Showing 13 changed files with 494 additions and 13 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
benchmark/sample/*
benchmark/node_modules/*
example-runner/example-repos
spec-compliance-tests/babel-tests/babel-tests-checkout
spec-compliance-tests/test262/test262-checkout
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module.exports = {
"**/example-runner/**",
"**/generator/**",
"**/test/**",
"**/test262/**",
"**/spec-compliance-tests/**",
"**/script/**",
],
optionalDependencies: false,
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
lint-and-test:
name: "Lint and core tests"
name: "Lint, core tests, and spec compliance tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
Expand All @@ -20,6 +20,7 @@ jobs:
- run: yarn lint
- run: yarn test-with-coverage && yarn report-coverage
- run: yarn test262
- run: yarn check-babel-tests
test-older-node:
name: "Test on older node versions"
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ dist
dist-self-build
dist-types
example-runner/example-repos
test262/test262-checkout
spec-compliance-tests/test262/test262-checkout
spec-compliance-tests/babel-tests/babel-tests-checkout
integrations/gulp-plugin/dist
.nyc_output
coverage.lcov
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"scripts": {
"build": "sucrase-node script/build.ts",
"fast-build": "sucrase-node script/build.ts --fast",
"clean": "rm -rf ./build ./dist ./dist-self-build ./dist-types ./example-runner/example-repos ./test262/test262-checkout",
"clean": "rm -rf ./build ./dist ./dist-self-build ./dist-types ./example-runner/example-repos ./spec-compliance-tests/test262/test262-checkout ./spec-compliance-tests/babel-tests/babel-tests-checkout",
"generate": "sucrase-node generator/generate.ts",
"benchmark": "cd benchmark && yarn && sucrase-node ./benchmark.ts",
"benchmark-compare": "sucrase-node ./benchmark/compare-performance.ts",
Expand All @@ -27,7 +27,8 @@
"run-examples": "sucrase-node example-runner/example-runner.ts",
"test": "yarn lint && yarn test-only",
"test-only": "mocha './test/**/*.ts'",
"test262": "sucrase-node test262/run-test262.ts",
"test262": "sucrase-node spec-compliance-tests/test262/run-test262.ts",
"check-babel-tests": "sucrase-node spec-compliance-tests/babel-tests/check-babel-tests.ts",
"test-with-coverage": "nyc mocha './test/**/*.ts'",
"report-coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov"
},
Expand All @@ -45,6 +46,7 @@
"url": "https://github.com/alangpierce/sucrase/issues"
},
"devDependencies": {
"@babel/core": "^7.18.6",
"@types/glob": "^7",
"@types/mocha": "^9.1.1",
"@types/mz": "^2.7.4",
Expand Down
10 changes: 9 additions & 1 deletion script/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ async function checkSucrase(): Promise<void> {
await Promise.all([
run(`${TSC} --project . --noEmit`),
run(
`${ESLINT} ${["benchmark", "example-runner", "generator", "script", "src", "test", "test262"]
`${ESLINT} ${[
"benchmark",
"example-runner",
"generator",
"script",
"spec-compliance-tests",
"src",
"test",
]
.map((dir) => `'${dir}/**/*.ts'`)
.join(" ")}`,
),
Expand Down
9 changes: 9 additions & 0 deletions script/util/readFileContents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {readFile} from "mz/fs";

export async function readFileContents(path: string): Promise<string> {
return (await readFile(path)).toString();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function readJSONFileContents(path: string): Promise<any> {
return JSON.parse(await readFileContents(path));
}
6 changes: 6 additions & 0 deletions spec-compliance-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Spec compliance tests

This directory consists of integrations with externally-written test suites that
are designed to surface tricky cases. This is in contrast to the example-runner
directory, which tests Sucrase on realistic codebases, and the test directory,
which is the core test suite for Sucrase.
264 changes: 264 additions & 0 deletions spec-compliance-tests/babel-tests/check-babel-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/* eslint-disable no-console */
// @ts-ignore: Babel package missing types.
import * as babel from "@babel/core";
import {exists, readdir, readFile, stat} from "mz/fs";
import {join, relative, resolve} from "path";

import run from "../../script/run";
import {readFileContents, readJSONFileContents} from "../../script/util/readFileContents";
import {transform, Transform} from "../../src";

const BABEL_TESTS_DIR = "./spec-compliance-tests/babel-tests/babel-tests-checkout";
const FIXTURES_DIR = `${BABEL_TESTS_DIR}/packages/babel-parser/test/fixtures`;
const BABEL_REPO_URL = "https://github.com/babel/babel.git";
const BABEL_REVISION = "bcf8b2273b8cba44c9b93c8977f05d508bfc1b91";

const KNOWN_FAILURES = `
es2015/let/let-declaration-in-escape-id
es2015/yield/accessor-name-inst-computed-yield-expr
es2015/yield/basic-without-argument
es2015/yield/without-argument
es2018/async-generators/for-await-async-of
es2020/bigint/decimal-as-property-name
es2020/export-ns-from/ns-and-named
es2022/module-string-names/mixed
estree/class-private-property/flow
estree/module-string-names/mixed
experimental/decorators/export-decorated-class
experimental/decorators/export-default-decorated-class
flow/anonymous-function-no-parens-types/good_15
flow/arrows-in-ternaries/issue-13644
flow/arrows-in-ternaries/issue-58
flow/arrows-in-ternaries/param-type-and-return-type-like
flow/class-private-property/declare-field
flow/class-properties/declare-after-decorators
flow/class-properties/declare-field
flow/class-properties/declare-field-computed
flow/class-properties/declare-field-named-static
flow/class-properties/declare-field-with-type
flow/class-properties/declare-static-field
flow/classes/good_01
flow/declare-export/export-class
flow/declare-export/export-default-union
flow/declare-export/export-from
flow/declare-export/export-function
flow/declare-export/export-interface
flow/declare-export/export-interface-and-var
flow/declare-export/export-interface-commonjs
flow/declare-export/export-named-pattern
flow/declare-export/export-star
flow/declare-export/export-type
flow/declare-export/export-type-and-var
flow/declare-export/export-type-commonjs
flow/declare-export/export-type-star-from
flow/declare-export/export-var
flow/declare-module/3
flow/declare-module/4
flow/declare-module/5
flow/declare-module/6
flow/declare-module/9
flow/module-string-names/mixed
flow/multiple-declarations/declare-class
flow/object-types/getter-key-is-keyword
flow/opaque-type-alias/opaque_subtype_allow_export
flow/opaque-type-alias/opaque_type_allow_export
flow/regression/issue-166
flow/scope/declare-module
flow/this-annotation/function-type
flow/typecasts/yield
jsx/basic/3
typescript/cast/as
typescript/export/as-namespace
typescript/import/export-import
typescript/import/export-import-require
typescript/import/export-import-type-as-identifier
typescript/import/export-import-type-require
typescript/import/import-default-id-type
typescript/import/type-asi
typescript/import/type-equals-require
typescript/type-arguments/instantiation-expression-binary-operator
`
.split("\n")
.filter((s) => s);

interface ResultSummary {
numPassed: number;
numFailed: number;
numSkipped: number;
failures: Array<string>;
}

/**
* Script that clones the Babel repo, walks its parser tests, and tries them in
* Sucrase. If they fail in Sucrase but pass with Babel in a Sucrase-like
* configuration, this likely indicates a syntax edge case that Sucrase isn't
* handling correctly. With any fixes, new tests should be added to the core
* Sucrase test suite, but this suite helps provide confidence that Sucrase is
* handling the important language edge cases.
*/
async function main(): Promise<void> {
if (!(await exists(BABEL_TESTS_DIR))) {
console.log(`Directory ${BABEL_TESTS_DIR} not found, cloning a new one.`);
await run(`git clone ${BABEL_REPO_URL} ${BABEL_TESTS_DIR}`);
}

// Force a specific revision so we don't get a breakage from changes to the main branch.
const originalCwd = process.cwd();
try {
process.chdir(BABEL_TESTS_DIR);
await run(`git reset --hard ${BABEL_REVISION}`);
await run(`git clean -f`);
} catch (e) {
await run("git fetch");
await run(`git reset --hard ${BABEL_REVISION}`);
await run(`git clean -f`);
} finally {
process.chdir(originalCwd);
}

console.log("Checking babel tests...");
const resultSummary: ResultSummary = {
numPassed: 0,
numFailed: 0,
numSkipped: 0,
failures: [],
};

await checkTests(FIXTURES_DIR, resultSummary);
reportSummary(resultSummary);
}

function reportSummary({numPassed, numFailed, numSkipped, failures}: ResultSummary): void {
const unexpectedFailures = failures.filter((dir) => !KNOWN_FAILURES.includes(dir));
const unexpectedPassed = KNOWN_FAILURES.filter((dir) => !failures.includes(dir));

console.log();
console.log("Failures, including expected failures:");
console.log(failures.join("\n"));
console.log();
console.log(
`Summary: ${numFailed} failed (${KNOWN_FAILURES.length} expected), ${numPassed} passed, ${numSkipped} skipped`,
);
console.log();

if (unexpectedPassed.length > 0) {
console.log("The following tests passed even though they are marked as failing:");
console.log(unexpectedPassed.join("\n"));
console.log();
process.exitCode = 1;
}

if (unexpectedFailures.length > 0) {
console.log("The following tests failed unexpectedly:");
console.log(unexpectedFailures.join("\n"));
console.log();
process.exitCode = 1;
}
}

async function checkTests(dir: string, resultSummary: ResultSummary): Promise<void> {
for (const child of await readdir(dir)) {
const childPath = join(dir, child);
if ((await stat(childPath)).isDirectory()) {
await checkTests(childPath, resultSummary);
}
}
await checkTestForDir(dir, resultSummary);
}

async function checkTestForDir(dir: string, resultSummary: ResultSummary): Promise<void> {
const displayDir = relative(FIXTURES_DIR, dir);
const outputJSONPath = join(dir, "output.json");
if (!(await exists(outputJSONPath))) {
return;
}

const outputJSON = JSON.parse((await readFile(outputJSONPath)).toString());
if (outputJSON.throws || outputJSON.errors) {
console.log(`SKIPPED: ${displayDir} (expects error)`);
resultSummary.numSkipped++;
} else {
const code = await getTestCode(dir);
const babelPlugins = await getBabelPlugins(dir);

const sucraseTransforms = [];
if (babelPlugins.includes("typescript")) {
sucraseTransforms.push("typescript");
}

try {
runSucrase(code, babelPlugins);
console.log(`PASSED: ${displayDir}`);
resultSummary.numPassed++;
} catch (e) {
// If Babel fails on this case as well, don't consider it an error.
try {
runBabel(code, babelPlugins);
console.log(`FAILED: ${displayDir}`);
console.log(e);
resultSummary.numFailed++;
resultSummary.failures.push(displayDir);
} catch (e2) {
console.log(`SKIPPED: ${displayDir} (Babel had parsing error)`);
console.log(e2);
resultSummary.numSkipped++;
}
}
}
}

async function getTestCode(dir: string): Promise<string> {
for (const extension of [".js", ".ts", ".tsx", ".mjs", ".cjs"]) {
const filePath = join(dir, `input${extension}`);
if (await exists(filePath)) {
return readFileContents(filePath);
}
}
throw new Error(`Unable to find code file in ${dir}`);
}

/**
* Get the configured babel plugins for the given test, which requires
* traversing parent directories for options.json files.
*/
async function getBabelPlugins(testDir: string): Promise<Array<string>> {
const plugins: Array<string> = [];
let dir = testDir;
while (resolve(dir) !== resolve(FIXTURES_DIR)) {
const optionsJSONPath = join(dir, "options.json");
if (await exists(optionsJSONPath)) {
const options = await readJSONFileContents(optionsJSONPath);
if (options.plugins) {
plugins.push(
...options.plugins.map((option: string | [string, ...Array<unknown>]) =>
typeof option === "string" ? option : option[0],
),
);
}
}
dir = resolve(dir, "..");
}
return plugins;
}

function runSucrase(code: string, babelPlugins: Array<string>): void {
const transforms: Array<Transform> = (["jsx", "flow", "typescript"] as const).filter((t) =>
babelPlugins.includes(t),
);
transform(code, {transforms});
transform(code, {transforms: [...transforms, "imports"]});
}

function runBabel(code: string, babelPlugins: Array<string>): void {
const plugins: Array<unknown> = ["jsx", "flow", "typescript"].filter((t) =>
babelPlugins.includes(t),
);
plugins.push(["decorators", {version: "2021-12", decoratorsBeforeExport: false}]);
babel.parse(code, {sourceType: "module", parserOpts: {plugins}});
}

main().catch((e) => {
console.error("Unhandled error:");
console.error(e);
process.exitCode = 1;
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import chalk from "chalk";
import {exec} from "mz/child_process";
import {exists} from "mz/fs";

import run from "../script/run";
import run from "../../script/run";

const TEST262_HARNESS = "./node_modules/.bin/test262-harness";
const TEST262_DIR = "./test262/test262-checkout";
const TEST262_DIR = "./spec-compliance-tests/test262/test262-checkout";
const TEST262_REPO_URL = "https://github.com/tc39/test262.git";
const TEST262_REVISION = "157b18d16b5d52501c4d75ac422d3a80bfad1c17";

Expand Down Expand Up @@ -47,7 +47,7 @@ async function main(): Promise<void> {
console.log("Running test262...");
const harnessStdout = (
await exec(`${TEST262_HARNESS} \
--preprocessor "./test262/test262Preprocessor.js" \
--preprocessor "./spec-compliance-tests/test262/test262Preprocessor.js" \
--reporter "json" \
"${TEST262_DIR}/test/language/expressions/coalesce/**/*.js" \
"${TEST262_DIR}/test/language/expressions/optional-chaining/**/*.js"`)
Expand Down
Loading

0 comments on commit d304d24

Please sign in to comment.