diff --git a/.eslintrc.js b/.eslintrc.js index da6fde4a4d..e858ee9cbd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -178,6 +178,7 @@ module.exports = { __UMD__: true, __EXPERIMENTAL__: true, __VARIANT__: true, + gate: true, trustedTypes: true, }, }; diff --git a/scripts/babel/__tests__/transform-test-gate-pragma-test.js b/scripts/babel/__tests__/transform-test-gate-pragma-test.js new file mode 100644 index 0000000000..cd5ff81a80 --- /dev/null +++ b/scripts/babel/__tests__/transform-test-gate-pragma-test.js @@ -0,0 +1,201 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +'use strict'; + +describe('transform-test-gate-pragma', () => { + // Fake runtime + // eslint-disable-next-line no-unused-vars + const _test_gate = (gateFn, testName, cb) => { + test(testName, (...args) => { + shouldPass = gateFn(context); + return cb(...args); + }); + }; + + // eslint-disable-next-line no-unused-vars + const _test_gate_focus = (gateFn, testName, cb) => { + // NOTE: Tests in this file are not actually focused because the calls to + // `test.only` and `fit` are compiled to `_test_gate_focus`. So if you want + // to focus something, swap the following `test` call for `test.only`. + test(testName, (...args) => { + shouldPass = gateFn(context); + isFocused = true; + return cb(...args); + }); + }; + + // Feature flags, environment variables, etc. We can configure this in + // our test set up. + const context = { + flagThatIsOff: false, + flagThatIsOn: true, + environment: 'fake-environment', + }; + + let shouldPass; + let isFocused; + beforeEach(() => { + shouldPass = null; + isFocused = false; + }); + + test('no pragma', () => { + expect(shouldPass).toBe(null); + }); + + // unrelated comment + test('no pragma, unrelated comment', () => { + expect(shouldPass).toBe(null); + }); + + // @gate flagThatIsOn + test('basic positive test', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOff + test('basic negative test', () => { + expect(shouldPass).toBe(false); + }); + + // @gate flagThatIsOn + it('it method', () => { + expect(shouldPass).toBe(true); + }); + + /* eslint-disable jest/no-focused-tests */ + + // @gate flagThatIsOn + test.only('test.only', () => { + expect(isFocused).toBe(true); + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOff + it.only('it.only', () => { + expect(isFocused).toBe(true); + expect(shouldPass).toBe(false); + }); + + // @gate flagThatIsOn + fit('fit', () => { + expect(isFocused).toBe(true); + expect(shouldPass).toBe(true); + }); + + /* eslint-enable jest/no-focused-tests */ + + // @gate !flagThatIsOff + test('flag negation', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOn + // @gate !flagThatIsOff + test('multiple gates', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOn + // @gate flagThatIsOff + test('multiple gates 2', () => { + expect(shouldPass).toBe(false); + }); + + // @gate !flagThatIsOff && flagThatIsOn + test('&&', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOff || flagThatIsOn + test('||', () => { + expect(shouldPass).toBe(true); + }); + + // @gate (flagThatIsOn || flagThatIsOff) && flagThatIsOn + test('groups', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOn == !flagThatIsOff + test('==', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOn === !flagThatIsOff + test('===', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOn != !flagThatIsOff + test('!=', () => { + expect(shouldPass).toBe(false); + }); + + // @gate flagThatIsOn != !flagThatIsOff + test('!==', () => { + expect(shouldPass).toBe(false); + }); + + // @gate flagThatIsOn === true + test('true', () => { + expect(shouldPass).toBe(true); + }); + + // @gate flagThatIsOff === false + test('false', () => { + expect(shouldPass).toBe(true); + }); + + // @gate environment === "fake-environment" + test('double quoted strings', () => { + expect(shouldPass).toBe(true); + }); + + // @gate environment === 'fake-environment' + test('single quoted strings', () => { + expect(shouldPass).toBe(true); + }); +}); + +describe('transform test-gate-pragma: actual runtime', () => { + // These tests use the actual gating runtime used by the rest of our + // test suite. + + // @gate __DEV__ + test('__DEV__', () => { + if (!__DEV__) { + throw Error("Doesn't work in production!"); + } + }); + + // Always should fail because of the unguarded console.error + // @gate false + test('works with console.error tracking', () => { + console.error('Should cause test to fail'); + }); + + // Always should fail because of the unguarded console.warn + // @gate false + test('works with console.warn tracking', () => { + console.warn('Should cause test to fail'); + }); + + // @gate false + test('works with console tracking if error is thrown before end of test', () => { + console.warn('Please stop that!'); + console.error('Stop that!'); + throw Error('I told you to stop!'); + }); +}); + +describe('dynamic gate method', () => { + // @gate experimental && __DEV__ + test('returns same conditions as pragma', () => { + expect(gate(ctx => ctx.experimental && ctx.__DEV__)).toBe(true); + }); +}); diff --git a/scripts/babel/transform-test-gate-pragma.js b/scripts/babel/transform-test-gate-pragma.js new file mode 100644 index 0000000000..f2586a0a94 --- /dev/null +++ b/scripts/babel/transform-test-gate-pragma.js @@ -0,0 +1,330 @@ +'use strict'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +function transform(babel) { + const {types: t} = babel; + + // A very stupid subset of pseudo-JavaScript, used to run tests conditionally + // based on the environment. + // + // Input: + // @gate a && (b || c) + // test('some test', () => {/*...*/}) + // + // Output: + // @gate a && (b || c) + // _test_gate(ctx => ctx.a && (ctx.b || ctx.c), 'some test', () => {/*...*/}); + // + // expression → binary ( ( "||" | "&&" ) binary)* ; + // binary → unary ( ( "==" | "!=" | "===" | "!==" ) unary )* ; + // unary → "!" primary + // | primary ; + // primary → NAME | STRING | BOOLEAN + // | "(" expression ")" ; + function tokenize(code) { + const tokens = []; + let i = 0; + while (i < code.length) { + let char = code[i]; + // Double quoted strings + if (char === '"') { + let string = ''; + i++; + do { + if (i > code.length) { + throw Error('Missing a closing quote'); + } + char = code[i++]; + if (char === '"') { + break; + } + string += char; + } while (true); + tokens.push({type: 'string', value: string}); + continue; + } + + // Single quoted strings + if (char === "'") { + let string = ''; + i++; + do { + if (i > code.length) { + throw Error('Missing a closing quote'); + } + char = code[i++]; + if (char === "'") { + break; + } + string += char; + } while (true); + tokens.push({type: 'string', value: string}); + continue; + } + + // Whitespace + if (/\s/.test(char)) { + if (char === '\n') { + return tokens; + } + i++; + continue; + } + + const next3 = code.substring(i, i + 3); + if (next3 === '===') { + tokens.push({type: '=='}); + i += 3; + continue; + } + if (next3 === '!==') { + tokens.push({type: '!='}); + i += 3; + continue; + } + + const next2 = code.substring(i, i + 2); + switch (next2) { + case '&&': + case '||': + case '==': + case '!=': + tokens.push({type: next2}); + i += 2; + continue; + } + + switch (char) { + case '(': + case ')': + case '!': + tokens.push({type: char}); + i++; + continue; + } + + // Names + const nameRegex = /[a-zA-Z_$][0-9a-zA-Z_$]*/y; + nameRegex.lastIndex = i; + const match = nameRegex.exec(code); + if (match !== null) { + const name = match[0]; + switch (name) { + case 'true': { + tokens.push({type: 'boolean', value: true}); + break; + } + case 'false': { + tokens.push({type: 'boolean', value: false}); + break; + } + default: { + tokens.push({type: 'name', name}); + } + } + i += name.length; + continue; + } + + throw Error('Invalid character: ' + char); + } + return tokens; + } + + function parse(code, ctxIdentifier) { + const tokens = tokenize(code); + + let i = 0; + function parseExpression() { + let left = parseBinary(); + while (true) { + const token = tokens[i]; + if (token !== undefined) { + switch (token.type) { + case '||': + case '&&': { + i++; + const right = parseBinary(); + if (right === null) { + throw Error('Missing expression after ' + token.type); + } + left = t.logicalExpression(token.type, left, right); + continue; + } + } + } + break; + } + return left; + } + + function parseBinary() { + let left = parseUnary(); + while (true) { + const token = tokens[i]; + if (token !== undefined) { + switch (token.type) { + case '==': + case '!=': { + i++; + const right = parseUnary(); + if (right === null) { + throw Error('Missing expression after ' + token.type); + } + left = t.binaryExpression(token.type, left, right); + continue; + } + } + } + break; + } + return left; + } + + function parseUnary() { + const token = tokens[i]; + if (token !== undefined) { + if (token.type === '!') { + i++; + const argument = parseUnary(); + return t.unaryExpression('!', argument); + } + } + return parsePrimary(); + } + + function parsePrimary() { + const token = tokens[i]; + switch (token.type) { + case 'boolean': { + i++; + return t.booleanLiteral(token.value); + } + case 'name': { + i++; + return t.memberExpression(ctxIdentifier, t.identifier(token.name)); + } + case 'string': { + i++; + return t.stringLiteral(token.value); + } + case '(': { + i++; + const expression = parseExpression(); + const closingParen = tokens[i]; + if (closingParen === undefined || closingParen.type !== ')') { + throw Error('Expected closing )'); + } + i++; + return expression; + } + default: { + throw Error('Unexpected token: ' + token.type); + } + } + } + + const program = parseExpression(); + if (tokens[i] !== undefined) { + throw Error('Unexpected token'); + } + return program; + } + + function buildGateCondition(comments) { + let conditions = null; + for (const line of comments) { + const commentStr = line.value.trim(); + if (commentStr.startsWith('@gate ')) { + const code = commentStr.slice(6); + const ctxIdentifier = t.identifier('ctx'); + const condition = parse(code, ctxIdentifier); + if (conditions === null) { + conditions = [condition]; + } else { + conditions.push(condition); + } + } + } + if (conditions !== null) { + let condition = conditions[0]; + for (let i = 1; i < conditions.length; i++) { + const right = conditions[i]; + condition = t.logicalExpression('&&', condition, right); + } + return condition; + } else { + return null; + } + } + + return { + name: 'test-gate-pragma', + visitor: { + ExpressionStatement(path) { + const statement = path.node; + const expression = statement.expression; + if (expression.type === 'CallExpression') { + const callee = expression.callee; + switch (callee.type) { + case 'Identifier': { + if ( + callee.name === 'test' || + callee.name === 'it' || + callee.name === 'fit' + ) { + const comments = statement.leadingComments; + if (comments !== undefined) { + const condition = buildGateCondition(comments); + if (condition !== null) { + callee.name = + callee.name === 'fit' ? '_test_gate_focus' : '_test_gate'; + expression.arguments = [ + t.arrowFunctionExpression( + [t.identifier('ctx')], + condition + ), + ...expression.arguments, + ]; + } + } + } + break; + } + case 'MemberExpression': { + if ( + callee.object.type === 'Identifier' && + (callee.object.name === 'test' || + callee.object.name === 'it') && + callee.property.type === 'Identifier' && + callee.property.name === 'only' + ) { + const comments = statement.leadingComments; + if (comments !== undefined) { + const condition = buildGateCondition(comments); + if (condition !== null) { + statement.expression = t.callExpression( + t.identifier('_test_gate_focus'), + [ + t.arrowFunctionExpression( + [t.identifier('ctx')], + condition + ), + ...expression.arguments, + ] + ); + } + } + } + break; + } + } + } + return; + }, + }, + }; +} + +module.exports = transform; diff --git a/scripts/jest/TestFlags.js b/scripts/jest/TestFlags.js new file mode 100644 index 0000000000..100dd7c5b8 --- /dev/null +++ b/scripts/jest/TestFlags.js @@ -0,0 +1,73 @@ +'use strict'; + +// These flags can be in a @gate pragma to declare that a test depends on +// certain conditions. They're like GKs. +// +// Examples: +// // @gate enableBlocksAPI +// test('uses an unstable API', () => {/*...*/}) +// +// // @gate __DEV__ +// test('only passes in development', () => {/*...*/}) +// +// Most flags are defined in ReactFeatureFlags. If it's defined there, you don't +// have to do anything extra here. +// +// There are also flags based on the environment, like __DEV__. Feel free to +// add new flags and aliases below. +// +// You can also combine flags using multiple gates: +// +// // @gate enableBlocksAPI +// // @gate __DEV__ +// test('both conditions must pass', () => {/*...*/}) +// +// Or using logical operators +// // @gate enableBlocksAPI && __DEV__ +// test('both conditions must pass', () => {/*...*/}) +// +// Negation also works: +// // @gate !deprecateLegacyContext +// test('uses a deprecated feature', () => {/*...*/}) + +// These flags are based on the environment and don't change for the entire +// test run. +const environmentFlags = { + __DEV__, + build: __DEV__ ? 'development' : 'production', + experimental: __EXPERIMENTAL__, + stable: !__EXPERIMENTAL__, +}; + +function getTestFlags() { + // These are required on demand because some of our tests mutate them. We try + // not to but there are exceptions. + const featureFlags = require('shared/ReactFeatureFlags'); + + // Return a proxy so we can throw if you attempt to access a flag that + // doesn't exist. + return new Proxy( + { + // Feature flag aliases + old: featureFlags.enableNewReconciler === true, + new: featureFlags.enableNewReconciler === true, + + ...featureFlags, + ...environmentFlags, + }, + { + get(flags, flagName) { + const flagValue = flags[flagName]; + if (typeof flagValue !== 'boolean' && typeof flagName === 'string') { + throw Error( + `Feature flag "${flagName}" does not exist. See TestFlags.js ` + + 'for more details.' + ); + } + return flagValue; + }, + } + ); +} + +exports.getTestFlags = getTestFlags; diff --git a/scripts/jest/preprocessor.js b/scripts/jest/preprocessor.js index ee8de9408e..f57005c940 100644 --- a/scripts/jest/preprocessor.js +++ b/scripts/jest/preprocessor.js @@ -25,6 +25,9 @@ const pathToBabelPluginAsyncToGenerator = require.resolve( const pathToTransformInfiniteLoops = require.resolve( '../babel/transform-prevent-infinite-loops' ); +const pathToTransformTestGatePragma = require.resolve( + '../babel/transform-test-gate-pragma' +); const pathToBabelrc = path.join(__dirname, '..', '..', 'babel.config.js'); const pathToErrorCodes = require.resolve('../error-codes/codes.json'); @@ -42,6 +45,7 @@ const babelOptions = { require.resolve('@babel/plugin-transform-react-jsx-source'), pathToTransformInfiniteLoops, + pathToTransformTestGatePragma, // This optimization is important for extremely performance-sensitive (e.g. React source). // It's okay to disable it for tests. @@ -99,6 +103,7 @@ module.exports = { pathToBabelrc, pathToBabelPluginDevWithCode, pathToTransformInfiniteLoops, + pathToTransformTestGatePragma, pathToErrorCodes, ]), }; diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 8a33db5af2..5200e2e973 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -3,6 +3,7 @@ const chalk = require('chalk'); const util = require('util'); const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError'); +const {getTestFlags} = require('./TestFlags'); if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { // Inside the class equivalence tester, we have a custom environment, let's @@ -66,8 +67,9 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { } }); - ['error', 'warn'].forEach(methodName => { - const unexpectedConsoleCallStacks = []; + // TODO: Consider consolidating this with `yieldValue`. In both cases, tests + // should not be allowed to exit without asserting on the entire log. + const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => { const newMethod = function(format, ...args) { // Ignore uncaught errors reported by jsdom // and React addendums because they're too noisy. @@ -87,55 +89,77 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { console[methodName] = newMethod; - env.beforeEach(() => { - unexpectedConsoleCallStacks.length = 0; - }); + return newMethod; + }; - env.afterEach(() => { - if (console[methodName] !== newMethod && !isSpy(console[methodName])) { - throw new Error( - `Test did not tear down console.${methodName} mock properly.` - ); - } + const flushUnexpectedConsoleCalls = ( + mockMethod, + methodName, + expectedMatcher, + unexpectedConsoleCallStacks + ) => { + if (console[methodName] !== mockMethod && !isSpy(console[methodName])) { + throw new Error( + `Test did not tear down console.${methodName} mock properly.` + ); + } + if (unexpectedConsoleCallStacks.length > 0) { + const messages = unexpectedConsoleCallStacks.map( + ([stack, message]) => + `${chalk.red(message)}\n` + + `${stack + .split('\n') + .map(line => chalk.gray(line)) + .join('\n')}` + ); - if (unexpectedConsoleCallStacks.length > 0) { - const messages = unexpectedConsoleCallStacks.map( - ([stack, message]) => - `${chalk.red(message)}\n` + - `${stack - .split('\n') - .map(line => chalk.gray(line)) - .join('\n')}` - ); + const message = + `Expected test not to call ${chalk.bold( + `console.${methodName}()` + )}.\n\n` + + 'If the warning is expected, test for it explicitly by:\n' + + `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` + + `matcher, or...\n` + + `2. Mock it out using ${chalk.bold( + 'spyOnDev' + )}(console, '${methodName}') or ${chalk.bold( + 'spyOnProd' + )}(console, '${methodName}'), and test that the warning occurs.`; - let expectedMatcher; - switch (methodName) { - case 'warn': - expectedMatcher = 'toWarnDev'; - break; - case 'error': - expectedMatcher = 'toErrorDev'; - break; - default: - throw new Error('No matcher for ' + methodName); - } - const message = - `Expected test not to call ${chalk.bold( - `console.${methodName}()` - )}.\n\n` + - 'If the warning is expected, test for it explicitly by:\n' + - `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` + - `matcher, or...\n` + - `2. Mock it out using ${chalk.bold( - 'spyOnDev' - )}(console, '${methodName}') or ${chalk.bold( - 'spyOnProd' - )}(console, '${methodName}'), and test that the warning occurs.`; + throw new Error(`${message}\n\n${messages.join('\n\n')}`); + } + }; - throw new Error(`${message}\n\n${messages.join('\n\n')}`); - } - }); - }); + const unexpectedErrorCallStacks = []; + const unexpectedWarnCallStacks = []; + + const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks); + const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks); + + const flushAllUnexpectedConsoleCalls = () => { + flushUnexpectedConsoleCalls( + errorMethod, + 'error', + 'toErrorDev', + unexpectedErrorCallStacks + ); + flushUnexpectedConsoleCalls( + warnMethod, + 'warn', + 'toWarnDev', + unexpectedWarnCallStacks + ); + unexpectedErrorCallStacks.length = 0; + unexpectedWarnCallStacks.length = 0; + }; + + const resetAllUnexpectedConsoleCalls = () => { + unexpectedErrorCallStacks.length = 0; + unexpectedWarnCallStacks.length = 0; + }; + + env.beforeEach(resetAllUnexpectedConsoleCalls); + env.afterEach(flushAllUnexpectedConsoleCalls); if (process.env.NODE_ENV === 'production') { // In production, we strip error messages and turn them into codes. @@ -215,10 +239,10 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { global.Error = ErrorProxy; } - const expectExperimentalToFail = async callback => { + const expectTestToFail = async (callback, errorMsg) => { if (callback.length > 0) { throw Error( - 'Experimental test helpers do not support `done` callback. Return a ' + + 'Gated test helpers do not support the `done` callback. Return a ' + 'promise instead.' ); } @@ -231,15 +255,19 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { ) { await maybePromise; } + // Flush unexpected console calls inside the test itself, instead of in + // `afterEach` like we normally do. `afterEach` is too late because if it + // throws, we won't have captured it. + flushAllUnexpectedConsoleCalls(); } catch (error) { // Failed as expected + resetAllUnexpectedConsoleCalls(); return; } - throw Error( - 'Tests marked experimental are expected to fail, but this one passed.' - ); + throw Error(errorMsg); }; + // TODO: Deprecate these helpers in favor of @gate pragma const it = global.it; const fit = global.fit; const xit = global.xit; @@ -248,25 +276,71 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { fit.experimental = it.only.experimental = it.experimental.only = fit; xit.experimental = it.skip.experimental = it.experimental.skip = xit; } else { + const errorMessage = + 'Tests marked experimental are expected to fail, but this one passed.'; it.experimental = (message, callback) => { it(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () => - expectExperimentalToFail(callback)); + expectTestToFail(callback, errorMessage)); }; fit.experimental = it.only.experimental = it.experimental.only = ( message, callback ) => { fit(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () => - expectExperimentalToFail(callback)); + expectTestToFail(callback, errorMessage)); }; xit.experimental = it.skip.experimental = it.experimental.skip = ( message, callback ) => { xit(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () => - expectExperimentalToFail(callback)); + expectTestToFail(callback, errorMessage)); }; } + const gatedErrorMessage = 'Gated test was expected to fail, but it passed.'; + global._test_gate = (gateFn, testName, callback) => { + let shouldPass; + try { + const flags = getTestFlags(); + shouldPass = gateFn(flags); + } catch (e) { + test(testName, () => { + throw e; + }); + return; + } + if (shouldPass) { + test(testName, callback); + } else { + test(`[GATED, SHOULD FAIL] ${testName}`, () => + expectTestToFail(callback, gatedErrorMessage)); + } + }; + global._test_gate_focus = (gateFn, testName, callback) => { + let shouldPass; + try { + const flags = getTestFlags(); + shouldPass = gateFn(flags); + } catch (e) { + test.only(testName, () => { + throw e; + }); + return; + } + if (shouldPass) { + test.only(testName, callback); + } else { + test.only(`[GATED, SHOULD FAIL] ${testName}`, () => + expectTestToFail(callback, gatedErrorMessage)); + } + }; + + // Dynamic version of @gate pragma + global.gate = fn => { + const flags = getTestFlags(); + return fn(flags); + }; + require('jasmine-check').install(); }