[snap] Refactor fixture utils to separate package

--- 

Move shared functions to `fixture-test-utils` package.
This commit is contained in:
Mofei Zhang
2023-08-14 19:26:01 -04:00
parent 02e8cf9790
commit 5669fc73c0
11 changed files with 375 additions and 309 deletions
@@ -0,0 +1,25 @@
{
"name": "fixture-test-utils",
"version": "1.0.0",
"main": "dist/index.js",
"license": "MIT",
"scripts": {
"build": "rimraf dist && tsc",
"test": "echo 'no tests'",
"prettier": "prettier --write src"
},
"dependencies": {
"@parcel/watcher": "^2.1.0",
"chalk": "4",
"readline": "^1.3.0",
"typescript": "^5.1.0",
"yargs": "^17.7.1"
},
"devDependencies": {
"@types/node": "^18.7.18",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"prettier": "2.8.8",
"rimraf": "^3.0.2"
}
}
@@ -0,0 +1,122 @@
import type { runReactForgetBabelPlugin as RunReactForgetBabelPlugin } from "babel-plugin-react-forget/src/Babel/RunReactForgetBabelPlugin";
import type { Effect, ValueKind } from "babel-plugin-react-forget/src/HIR";
const FlowPragmas = [/\/\/\s@flow$/gm, /\*\s@flow$/gm];
export function parseLanguage(source: string): "flow" | "typescript" {
let useFlow = false;
for (const flowPragma of FlowPragmas) {
useFlow ||= !!source.match(flowPragma);
}
return useFlow ? "flow" : "typescript";
}
export function transformFixtureInput(
input: string,
basename: string,
pluginFn: typeof RunReactForgetBabelPlugin,
includeAst: boolean = false
) {
// Extract the first line to quickly check for custom test directives
const firstLine = input.substring(0, input.indexOf("\n"));
let language = parseLanguage(firstLine);
let enableOnlyOnUseForgetDirective = false;
let gating = null;
let instrumentForget = null;
let panicOnBailout = true;
let memoizeJsxElements = true;
let enableAssumeHooksFollowRulesOfReact = false;
let enableTreatHooksAsFunctions = true;
let disableAllMemoization = false;
let validateRefAccessDuringRender = true;
let validateNoSetStateInRender = true;
let enableEmitFreeze = null;
let enableOptimizeFunctionExpressions = true;
let enableOnlyOnReactScript = false;
if (firstLine.indexOf("@forgetDirective") !== -1) {
enableOnlyOnUseForgetDirective = true;
}
if (firstLine.includes("@gating")) {
gating = {
source: "ReactForgetFeatureFlag",
importSpecifierName: "isForgetEnabled_Fixtures",
};
}
if (firstLine.includes("@instrumentForget")) {
instrumentForget = {
source: "react-forget-runtime",
importSpecifierName: "useRenderCounter",
};
}
if (firstLine.includes("@panicOnBailout false")) {
panicOnBailout = false;
}
if (firstLine.includes("@memoizeJsxElements false")) {
memoizeJsxElements = false;
}
if (firstLine.includes("@enableAssumeHooksFollowRulesOfReact true")) {
enableAssumeHooksFollowRulesOfReact = true;
}
if (firstLine.includes("@enableTreatHooksAsFunctions false")) {
enableTreatHooksAsFunctions = false;
}
if (firstLine.includes("@disableAllMemoization true")) {
disableAllMemoization = true;
}
if (firstLine.includes("@validateRefAccessDuringRender false")) {
validateRefAccessDuringRender = false;
}
if (firstLine.includes("@validateNoSetStateInRender false")) {
validateNoSetStateInRender = false;
}
if (firstLine.includes("@enableOptimizeFunctionExpressions false")) {
enableOptimizeFunctionExpressions = false;
}
if (firstLine.includes("@enableEmitFreeze")) {
enableEmitFreeze = {
source: "react-forget-runtime",
importSpecifierName: "makeReadOnly",
};
}
if (firstLine.indexOf("@reactScriptDirective") !== -1) {
enableOnlyOnReactScript = true;
language = "flow";
}
return pluginFn(input, basename, language, {
environment: {
customHooks: new Map([
[
"useFreeze",
{
valueKind: "frozen" as ValueKind,
effectKind: "freeze" as Effect,
},
],
]),
enableAssumeHooksFollowRulesOfReact,
enableFunctionCallSignatureOptimizations: true,
disableAllMemoization,
enableTreatHooksAsFunctions,
inlineUseMemo: true,
memoizeJsxElements,
validateHooksUsage: true,
validateRefAccessDuringRender,
validateFrozenLambdas: true,
validateNoSetStateInRender,
enableEmitFreeze,
enableOptimizeFunctionExpressions,
assertValidMutableRanges: true,
},
enableOnlyOnUseForgetDirective,
enableOnlyOnReactScript,
logger: null,
gating,
instrumentForget,
panicOnBailout,
isDev: true,
noEmit: false,
});
}
@@ -0,0 +1,24 @@
import path from "path";
// We assume this is run from `babel-plugin-react-forget`
export const COMPILER_PATH = path.join(
process.cwd(),
"dist",
"Babel",
"RunReactForgetBabelPlugin.js"
);
export const LOGGER_PATH = path.join(
process.cwd(),
"dist",
"Utils",
"logger.js"
);
export const FIXTURES_PATH = path.join(
process.cwd(),
"src",
"__tests__",
"fixtures",
"compiler"
);
export const FILTER_FILENAME = "testfilter.txt";
export const FILTER_PATH = path.join(process.cwd(), FILTER_FILENAME);
@@ -0,0 +1,161 @@
import fs from "fs/promises";
import glob from "glob";
import invariant from "invariant";
import path from "path";
import { FILTER_PATH, FIXTURES_PATH } from "./constants";
export type TestFilter =
| {
kind: "only";
paths: Array<string>;
}
| {
kind: "skip";
paths: Array<string>;
};
async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
export async function readTestFilter(): Promise<TestFilter | null> {
if (!(await exists(FILTER_PATH))) {
throw new Error(`testfilter file not found at ${FILTER_PATH}`);
}
const input = await fs.readFile(FILTER_PATH, "utf8");
const lines = input.trim().split("\n");
if (lines.length < 2) {
console.warn("Misformed filter file. Expected at least two lines.");
return null;
}
let filter: "only" | "skip" | null = null;
if (lines[0]!.indexOf("@only") !== -1) {
filter = "only";
}
if (lines[0]!.indexOf("@skip") !== -1) {
filter = "skip";
}
if (filter === null) {
console.warn(
"Misformed filter file. Expected first line to contain @only or @skip"
);
return null;
}
lines.shift();
return {
kind: filter,
paths: lines,
};
}
export type TestFixture = {
basename: string;
inputPath: string;
inputExists: boolean;
outputPath: string;
outputExists: boolean;
};
export function getFixtures(
filter: TestFilter | null
): Map<string, TestFixture> {
// search for fixtures within nested directories
const files = glob.sync(`**/*.{js,md}`, {
cwd: FIXTURES_PATH,
});
const fixtures: Map<string, TestFixture> = new Map();
for (const filePath of files) {
const basename = path.basename(
path.basename(filePath, ".js"),
".expect.md"
);
// "partial" paths do not include suffixes
const partialRelativePath = path.join(path.dirname(filePath), basename);
// Replicate jest test behavior
if (basename.startsWith("todo.")) {
continue;
}
if (filter) {
if (
filter.kind === "only" &&
filter.paths.indexOf(partialRelativePath) === -1
) {
continue;
} else if (
filter.kind === "skip" &&
filter.paths.indexOf(partialRelativePath) !== -1
) {
continue;
}
}
let fixtureInfo = fixtures.get(partialRelativePath);
if (fixtureInfo === undefined) {
const partialAbsolutePath = path.join(FIXTURES_PATH, partialRelativePath);
fixtureInfo = {
basename,
inputPath: `${partialAbsolutePath}.js`,
inputExists: false,
outputPath: `${partialAbsolutePath}.expect.md`,
outputExists: false,
};
fixtures.set(partialRelativePath, fixtureInfo);
}
if (filePath.endsWith(".js")) {
fixtureInfo.inputExists = true;
} else {
fixtureInfo.outputExists = true;
}
}
return fixtures;
}
function wrapWithTripleBackticks(s: string, ext: string | null = null): string {
return `\`\`\`${ext ?? ""}
${s}
\`\`\``;
}
export function writeOutputToString(
input: string,
output: string | null,
error: Error | null
) {
// leading newline intentional
let result = `
## Input
${wrapWithTripleBackticks(input, "javascript")}
`; // trailing newline + space internional
if (output != null) {
result += `
## Code
${output == null ? "[ none ]" : wrapWithTripleBackticks(output, "javascript")}
`;
} else {
result += "\n";
}
if (error != null) {
const errorMessage = error.message.replace(/^\/.*?:\s/, "");
result += `
## Error
${wrapWithTripleBackticks(errorMessage)}
\n`;
}
return result + ` `;
}
@@ -0,0 +1,3 @@
export * from "./constants";
export * from "./fixture-utils";
export * from "./compiler-utils";
@@ -0,0 +1,20 @@
{
"extends": "@tsconfig/node16-strictest/tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"jsx": "react-jsxdev",
// weaken strictness from preset
"importsNotUsedAsValues": "remove",
"noUncheckedIndexedAccess": false,
"noUnusedParameters": false,
"useUnknownInCatchVariables": false,
"target": "ES2015",
// ideally turn off only during dev, or on a per-file basis
"noUnusedLocals": false,
"sourceMap": true
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts"]
}
+3 -2
View File
@@ -9,7 +9,7 @@
"src"
],
"scripts": {
"build": "rimraf dist && tsc",
"build": "yarn workspace fixture-test-utils run build && rimraf dist && tsc",
"test": "echo 'no tests'",
"lint": "yarn eslint src"
},
@@ -22,7 +22,8 @@
"chalk": "4",
"readline": "^1.3.0",
"typescript": "^5.1.0",
"yargs": "^17.7.1"
"yargs": "^17.7.1",
"fixture-test-utils": "*"
},
"devDependencies": {
"@types/node": "^18.7.18",
@@ -6,9 +6,8 @@
*/
import type { runReactForgetBabelPlugin as RunReactForgetBabelPlugin } from 'babel-plugin-react-forget/src/Babel/RunReactForgetBabelPlugin';
import type { Effect, ValueKind } from 'babel-plugin-react-forget/src/HIR';
import { TestFixture, transformFixtureInput, writeOutputToString } from 'fixture-test-utils';
import fs from "fs/promises";
import { exists } from "./utils";
const originalConsoleError = console.error;
@@ -33,12 +32,6 @@ export type TestResult = {
unexpectedError: string | null;
};
export type TestFixture = {
basename: string;
inputPath: string;
outputPath: string;
};
export async function compile(
compilerPath: string,
loggerPath: string,
@@ -54,11 +47,11 @@ export async function compile(
clearRequireCache();
}
version = compilerVersion;
const { inputPath, outputPath, basename } = fixture;
const input = (await exists(inputPath))
const { inputPath, inputExists, outputPath, outputExists, basename } = fixture;
const input = inputExists
? await fs.readFile(inputPath, "utf8")
: null;
const expected = (await exists(outputPath))
const expected = outputExists
? await fs.readFile(outputPath, "utf8")
: null;
@@ -86,108 +79,7 @@ export async function compile(
// since console log order is non-deterministic
const shouldLogPragma = input.split("\n")[0].includes("@debug");
toggleLogging(isOnlyFixture && shouldLogPragma);
// Extract the first line to quickly check for custom test directives
const firstLine = input.substring(0, input.indexOf("\n"));
let language = parseLanguage(firstLine);
let enableOnlyOnUseForgetDirective = false;
let gating = null;
let instrumentForget = null;
let panicOnBailout = true;
let memoizeJsxElements = true;
let enableAssumeHooksFollowRulesOfReact = false;
let enableTreatHooksAsFunctions = true;
let disableAllMemoization = false;
let validateRefAccessDuringRender = true;
let validateNoSetStateInRender = true;
let enableEmitFreeze = null;
let enableOptimizeFunctionExpressions = true;
let enableOnlyOnReactScript = false;
if (firstLine.indexOf("@forgetDirective") !== -1) {
enableOnlyOnUseForgetDirective = true;
}
if (firstLine.includes("@gating")) {
gating = {
source: "ReactForgetFeatureFlag",
importSpecifierName: "isForgetEnabled_Fixtures",
};
}
if (firstLine.includes("@instrumentForget")) {
instrumentForget = {
source: "react-forget-runtime",
importSpecifierName: "useRenderCounter",
};
}
if (firstLine.includes("@panicOnBailout false")) {
panicOnBailout = false;
}
if (firstLine.includes("@memoizeJsxElements false")) {
memoizeJsxElements = false;
}
if (firstLine.includes("@enableAssumeHooksFollowRulesOfReact true")) {
enableAssumeHooksFollowRulesOfReact = true;
}
if (firstLine.includes("@enableTreatHooksAsFunctions false")) {
enableTreatHooksAsFunctions = false;
}
if (firstLine.includes("@disableAllMemoization true")) {
disableAllMemoization = true;
}
if (firstLine.includes("@validateRefAccessDuringRender false")) {
validateRefAccessDuringRender = false;
}
if (firstLine.includes("@validateNoSetStateInRender false")) {
validateNoSetStateInRender = false;
}
if (firstLine.includes("@enableOptimizeFunctionExpressions false")) {
enableOptimizeFunctionExpressions = false;
}
if (firstLine.includes("@enableEmitFreeze")) {
enableEmitFreeze = {
source: "react-forget-runtime",
importSpecifierName: "makeReadOnly",
};
}
if (firstLine.indexOf("@reactScriptDirective") !== -1) {
enableOnlyOnReactScript = true;
language = "flow";
}
code = runReactForgetBabelPlugin(input, basename, language, {
environment: {
customHooks: new Map([
[
"useFreeze",
{
valueKind: "frozen" as ValueKind,
effectKind: "freeze" as Effect,
},
],
]),
enableAssumeHooksFollowRulesOfReact,
enableFunctionCallSignatureOptimizations: true,
disableAllMemoization,
enableTreatHooksAsFunctions,
inlineUseMemo: true,
memoizeJsxElements,
validateHooksUsage: true,
validateRefAccessDuringRender,
validateFrozenLambdas: true,
validateNoSetStateInRender,
enableEmitFreeze,
enableOptimizeFunctionExpressions,
assertValidMutableRanges: true,
},
enableOnlyOnUseForgetDirective,
enableOnlyOnReactScript,
logger: null,
gating,
instrumentForget,
panicOnBailout,
isDev: true,
noEmit: false,
}).code;
code = transformFixtureInput(input, basename, runReactForgetBabelPlugin).code;
} catch (e) {
error = e;
}
@@ -202,92 +94,28 @@ export async function compile(
}
}
let output: string;
const output = writeOutputToString(input, code, error);
const expectError = basename.startsWith("error.");
let unexpectedError: string | null = null;
if (expectError) {
if (error === null) {
return {
inputPath,
outputPath,
actual: code,
expected,
unexpectedError: `Expected an error to be thrown for fixture: '${basename}', remove the 'error.' prefix if an error is not expected.`,
};
} else if (code != null) {
output = `${formatOutput(code)}\n${formatErrorOutput(error)}`;
} else {
output = formatErrorOutput(error);
unexpectedError = `Expected an error to be thrown for fixture: '${basename}', remove the 'error.' prefix if an error is not expected.`;
}
} else {
if (error !== null) {
return {
inputPath,
outputPath,
actual: code,
expected,
unexpectedError: `Expected fixture '${basename}' to succeed but it failed with error:\n\n${error.message}`,
};
unexpectedError = `Expected fixture '${basename}' to succeed but it failed with error:\n\n${error.message}`;
} else if (code == null || code.length === 0) {
unexpectedError = `Expected output for fixture '${basename}'.`;
}
if (code == null || code.length === 0) {
return {
inputPath,
outputPath,
actual: code,
expected,
unexpectedError: `Expected output for fixture '${basename}'.`,
};
}
output = formatOutput(code);
}
// leading newline intentional
const actual = `
## Input
${wrapWithTripleBackticks(input, "javascript")}
${output}
`; // trailing newline + space internional
console.error = originalConsoleError;
return {
inputPath,
outputPath,
actual,
actual: output,
expected,
unexpectedError: null,
unexpectedError,
};
}
function formatErrorOutput(error: Error): string {
error.message = error.message.replace(/^\/.*?:\s/, "");
return `
## Error
${wrapWithTripleBackticks(error.message)}
`;
}
function formatOutput(code: string): string {
return `
## Code
${wrapWithTripleBackticks(code, "javascript")}
`.trim();
}
function wrapWithTripleBackticks(s: string, ext: string | null = null): string {
return `\`\`\`${ext ?? ""}
${s}
\`\`\``;
}
const FlowPragmas = [/\/\/\s@flow$/gm, /\*\s@flow$/gm];
function parseLanguage(source: string): "flow" | "typescript" {
let useFlow = false;
for (const flowPragma of FlowPragmas) {
useFlow ||= !!source.match(flowPragma);
}
return useFlow ? "flow" : "typescript";
}
+1 -1
View File
@@ -17,7 +17,7 @@ if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.on("keypress", function (chunk, key) {
process.stdin.on("keypress", function (_, key) {
if (key && key.name === "c" && key.ctrl) {
// handle sigint
if (childProc) {
+3 -104
View File
@@ -7,8 +7,8 @@
import watcher from "@parcel/watcher";
import chalk from "chalk";
import { COMPILER_PATH, LOGGER_PATH, FIXTURES_PATH, FILTER_FILENAME, FILTER_PATH, readTestFilter, TestFilter } from 'fixture-test-utils';
import fs from "fs/promises";
import glob from "glob";
import invariant from "invariant";
import { diff } from "jest-diff";
import { Worker } from "jest-worker";
@@ -19,26 +19,10 @@ import ts from "typescript";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { TestResult } from "./compiler-worker";
import * as compiler from "./compiler-worker.js";
import { exists } from "./utils";
import * as compiler from "./compiler-worker";
import {getFixtures} from 'fixture-test-utils';
const WORKER_PATH = require.resolve("./compiler-worker.js");
const COMPILER_PATH = path.join(
process.cwd(),
"dist",
"Babel",
"RunReactForgetBabelPlugin.js"
);
const LOGGER_PATH = path.join(process.cwd(), "dist", "Utils", "logger.js");
const FIXTURES_PATH = path.join(
process.cwd(),
"src",
"__tests__",
"fixtures",
"compiler"
);
const FILTER_FILENAME = "testfilter.txt";
const FILTER_PATH = path.join(process.cwd(), FILTER_FILENAME);
readline.emitKeypressEvents(process.stdin);
@@ -109,49 +93,6 @@ function clearConsole() {
console.log("\u001Bc");
}
function getFixtures(
filter: TestFilter | null
): Map<string, compiler.TestFixture> {
// search for fixtures within nested directories
const files = glob.sync(`**/*.{js,md}`, {
cwd: FIXTURES_PATH,
});
const fixtures = new Map();
for (const filePath of files) {
const basename = path.basename(
path.basename(filePath, ".js"),
".expect.md"
);
// "partial" paths do not include suffixes
const partialRelativePath = path.join(path.dirname(filePath), basename);
const partialAbsolutePath = path.join(FIXTURES_PATH, partialRelativePath);
// Replicate jest test behavior
if (basename.startsWith("todo.")) {
continue;
}
if (filter) {
if (
filter.kind === "only" &&
filter.paths.indexOf(partialRelativePath) === -1
) {
continue;
} else if (
filter.kind === "skip" &&
filter.paths.indexOf(partialRelativePath) !== -1
) {
continue;
}
}
fixtures.set(partialRelativePath, {
basename,
inputPath: `${partialAbsolutePath}.js`,
outputPath: `${partialAbsolutePath}.expect.md`,
});
}
// console.log("fixtures!", JSON.stringify(Array.from(fixtures.keys())));
return fixtures;
}
/**
* Do a test run and return the test results
*/
@@ -395,48 +336,6 @@ enum Mode {
Update = "Update",
}
type TestFilter =
| {
kind: "only";
paths: Array<string>;
}
| {
kind: "skip";
paths: Array<string>;
};
async function readTestFilter(): Promise<TestFilter | null> {
if (!(await exists(FILTER_PATH))) {
throw new Error(`testfilter file not found at ${FILTER_PATH}`);
}
const input = await fs.readFile(FILTER_PATH, "utf8");
const lines = input.trim().split("\n");
if (lines.length < 2) {
console.warn("Misformed filter file. Expected at least two lines.");
return null;
}
let filter: "only" | "skip" | null = null;
if (lines[0]!.indexOf("@only") !== -1) {
filter = "only";
}
if (lines[0]!.indexOf("@skip") !== -1) {
filter = "skip";
}
if (filter === null) {
console.warn(
"Misformed filter file. Expected first line to contain @only or @skip"
);
return null;
}
lines.shift();
return {
kind: filter,
paths: lines,
};
}
/**
* Runs the compiler in watch or single-execution mode
*/
@@ -1,17 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import fs from "fs/promises";
export async function exists(file: string): Promise<boolean> {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}