@samitouri / QOS-React-2 / commits / 42d7c2e8f7

Add pragma for feature testing: @gate (#18581)

* Add pragma for feature testing: @gate The `@gate` pragma declares under which conditions a test is expected to pass. If the gate condition passes, then the test runs normally (same as if there were no pragma). If the conditional fails, then the test runs and is *expected to fail*. An alternative to `it.experimental` and similar proposals. Examples -------- Basic: ```js // @gate enableBlocksAPI test('passes only if Blocks API is available', () => {/*...*/}) ``` Negation: ```js // @gate !disableLegacyContext test('depends on a deprecated feature', () => {/*...*/}) ``` Multiple flags: ```js // @gate enableNewReconciler // @gate experimental test('needs both useEvent and Blocks', () => {/*...*/}) ``` Logical operators (yes, I'm sorry): ```js // @gate experimental && (enableNewReconciler || disableSchedulerTimeoutBasedOnReactExpirationTime) test('concurrent mode, doesn\'t work in old fork unless Scheduler timeout flag is disabled', () => {/*...*/}) ``` Strings, and comparion operators No use case yet but I figure eventually we'd use this to gate on different release channels: ```js // @gate channel === "experimental" || channel === "modern" test('works in OSS experimental or www modern', () => {/*...*/}) ``` How does it work? I'm guessing those last two examples might be controversial. Supporting those cases did require implementing a mini-parser. The output of the transform is very straightforward, though. Input: ```js // @gate a && (b || c) test('some test', () => {/*...*/}) ``` Output: ```js _test_gate(ctx => ctx.a && (ctx.b || ctx.c, 'some test'), () => {/*...*/}); ``` It also works with `it`, `it.only`, and `fit`. It leaves `it.skip` and `xit` alone because those tests are disabled anyway. `_test_gate` is a global method that I set up in our Jest config. It works about the same as the existing `it.experimental` helper. The context (`ctx`) argument is whatever we want it to be. I set it up so that it throws if you try to access a flag that doesn't exist. I also added some shortcuts for common gating conditions, like `old` and `new`: ```js // @gate experimental test('experimental feature', () => {/*...*/}) // @gate new test('only passes in new reconciler', () => {/*...*/}) ``` Why implement this as a pragma instead of a runtime API? - Doesn't require monkey patching built-in Jest methods. Instead it compiles to a runtime function that composes Jest's API. - Will be easy to upgrade if Jest ever overhauls their API or we switch to a different testing framework (unlikely but who knows). - It feels lightweight so hopefully people won't feel gross using it. For example, adding or removing a gate pragma will never affect the indentation of the test, unlike if you wrapped the test in a conditional block. * Compatibility with console error/warning tracking We patch console.error and console.warning to track unexpected calls in our tests. If there's an unexpected call, we usually throw inside an `afterEach` hook. However, that's too late for tests that we expect to fail, because our `_test_gate` runtime can't capture the error. So I also check for unexpected calls inside `_test_gate`. * Move test flags to dedicated file Added some instructions for how the flags are set up and how to use them. * Add dynamic version of gate API Receives same flags as the pragma. If we ever decide to revert the pragma, we can codemod them to use this instead.

Andrew Clark committed Apr 13, 2020 at 10:14 UTC 42d7c2e8f7cef5c7c874e0d9b148a284b7307d23
6 files changed +739 -55
.eslintrc.js
+1
@@ -178,6 +178,7 @@ module.exports = {
178 __UMD__: true,
179 __EXPERIMENTAL__: true,
180 __VARIANT__: true,
181 + gate: true,
182 trustedTypes: true,
183 },
184 };
scripts/babel/__tests__/transform-test-gate-pragma-test.js new
+201
@@ -0,0 +1,201 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +'use strict';
8 +
9 +describe('transform-test-gate-pragma', () => {
10 + // Fake runtime
11 + // eslint-disable-next-line no-unused-vars
12 + const _test_gate = (gateFn, testName, cb) => {
13 + test(testName, (...args) => {
14 + shouldPass = gateFn(context);
15 + return cb(...args);
16 + });
17 + };
18 +
19 + // eslint-disable-next-line no-unused-vars
20 + const _test_gate_focus = (gateFn, testName, cb) => {
21 + // NOTE: Tests in this file are not actually focused because the calls to
22 + // `test.only` and `fit` are compiled to `_test_gate_focus`. So if you want
23 + // to focus something, swap the following `test` call for `test.only`.
24 + test(testName, (...args) => {
25 + shouldPass = gateFn(context);
26 + isFocused = true;
27 + return cb(...args);
28 + });
29 + };
30 +
31 + // Feature flags, environment variables, etc. We can configure this in
32 + // our test set up.
33 + const context = {
34 + flagThatIsOff: false,
35 + flagThatIsOn: true,
36 + environment: 'fake-environment',
37 + };
38 +
39 + let shouldPass;
40 + let isFocused;
41 + beforeEach(() => {
42 + shouldPass = null;
43 + isFocused = false;
44 + });
45 +
46 + test('no pragma', () => {
47 + expect(shouldPass).toBe(null);
48 + });
49 +
50 + // unrelated comment
51 + test('no pragma, unrelated comment', () => {
52 + expect(shouldPass).toBe(null);
53 + });
54 +
55 + // @gate flagThatIsOn
56 + test('basic positive test', () => {
57 + expect(shouldPass).toBe(true);
58 + });
59 +
60 + // @gate flagThatIsOff
61 + test('basic negative test', () => {
62 + expect(shouldPass).toBe(false);
63 + });
64 +
65 + // @gate flagThatIsOn
66 + it('it method', () => {
67 + expect(shouldPass).toBe(true);
68 + });
69 +
70 + /* eslint-disable jest/no-focused-tests */
71 +
72 + // @gate flagThatIsOn
73 + test.only('test.only', () => {
74 + expect(isFocused).toBe(true);
75 + expect(shouldPass).toBe(true);
76 + });
77 +
78 + // @gate flagThatIsOff
79 + it.only('it.only', () => {
80 + expect(isFocused).toBe(true);
81 + expect(shouldPass).toBe(false);
82 + });
83 +
84 + // @gate flagThatIsOn
85 + fit('fit', () => {
86 + expect(isFocused).toBe(true);
87 + expect(shouldPass).toBe(true);
88 + });
89 +
90 + /* eslint-enable jest/no-focused-tests */
91 +
92 + // @gate !flagThatIsOff
93 + test('flag negation', () => {
94 + expect(shouldPass).toBe(true);
95 + });
96 +
97 + // @gate flagThatIsOn
98 + // @gate !flagThatIsOff
99 + test('multiple gates', () => {
100 + expect(shouldPass).toBe(true);
101 + });
102 +
103 + // @gate flagThatIsOn
104 + // @gate flagThatIsOff
105 + test('multiple gates 2', () => {
106 + expect(shouldPass).toBe(false);
107 + });
108 +
109 + // @gate !flagThatIsOff && flagThatIsOn
110 + test('&&', () => {
111 + expect(shouldPass).toBe(true);
112 + });
113 +
114 + // @gate flagThatIsOff || flagThatIsOn
115 + test('||', () => {
116 + expect(shouldPass).toBe(true);
117 + });
118 +
119 + // @gate (flagThatIsOn || flagThatIsOff) && flagThatIsOn
120 + test('groups', () => {
121 + expect(shouldPass).toBe(true);
122 + });
123 +
124 + // @gate flagThatIsOn == !flagThatIsOff
125 + test('==', () => {
126 + expect(shouldPass).toBe(true);
127 + });
128 +
129 + // @gate flagThatIsOn === !flagThatIsOff
130 + test('===', () => {
131 + expect(shouldPass).toBe(true);
132 + });
133 +
134 + // @gate flagThatIsOn != !flagThatIsOff
135 + test('!=', () => {
136 + expect(shouldPass).toBe(false);
137 + });
138 +
139 + // @gate flagThatIsOn != !flagThatIsOff
140 + test('!==', () => {
141 + expect(shouldPass).toBe(false);
142 + });
143 +
144 + // @gate flagThatIsOn === true
145 + test('true', () => {
146 + expect(shouldPass).toBe(true);
147 + });
148 +
149 + // @gate flagThatIsOff === false
150 + test('false', () => {
151 + expect(shouldPass).toBe(true);
152 + });
153 +
154 + // @gate environment === "fake-environment"
155 + test('double quoted strings', () => {
156 + expect(shouldPass).toBe(true);
157 + });
158 +
159 + // @gate environment === 'fake-environment'
160 + test('single quoted strings', () => {
161 + expect(shouldPass).toBe(true);
162 + });
163 +});
164 +
165 +describe('transform test-gate-pragma: actual runtime', () => {
166 + // These tests use the actual gating runtime used by the rest of our
167 + // test suite.
168 +
169 + // @gate __DEV__
170 + test('__DEV__', () => {
171 + if (!__DEV__) {
172 + throw Error("Doesn't work in production!");
173 + }
174 + });
175 +
176 + // Always should fail because of the unguarded console.error
177 + // @gate false
178 + test('works with console.error tracking', () => {
179 + console.error('Should cause test to fail');
180 + });
181 +
182 + // Always should fail because of the unguarded console.warn
183 + // @gate false
184 + test('works with console.warn tracking', () => {
185 + console.warn('Should cause test to fail');
186 + });
187 +
188 + // @gate false
189 + test('works with console tracking if error is thrown before end of test', () => {
190 + console.warn('Please stop that!');
191 + console.error('Stop that!');
192 + throw Error('I told you to stop!');
193 + });
194 +});
195 +
196 +describe('dynamic gate method', () => {
197 + // @gate experimental && __DEV__
198 + test('returns same conditions as pragma', () => {
199 + expect(gate(ctx => ctx.experimental && ctx.__DEV__)).toBe(true);
200 + });
201 +});
scripts/babel/transform-test-gate-pragma.js new
+330
@@ -0,0 +1,330 @@
1 +'use strict';
2 +
3 +/* eslint-disable no-for-of-loops/no-for-of-loops */
4 +
5 +function transform(babel) {
6 + const {types: t} = babel;
7 +
8 + // A very stupid subset of pseudo-JavaScript, used to run tests conditionally
9 + // based on the environment.
10 + //
11 + // Input:
12 + // @gate a && (b || c)
13 + // test('some test', () => {/*...*/})
14 + //
15 + // Output:
16 + // @gate a && (b || c)
17 + // _test_gate(ctx => ctx.a && (ctx.b || ctx.c), 'some test', () => {/*...*/});
18 + //
19 + // expression → binary ( ( "||" | "&&" ) binary)* ;
20 + // binary → unary ( ( "==" | "!=" | "===" | "!==" ) unary )* ;
21 + // unary → "!" primary
22 + // | primary ;
23 + // primary → NAME | STRING | BOOLEAN
24 + // | "(" expression ")" ;
25 + function tokenize(code) {
26 + const tokens = [];
27 + let i = 0;
28 + while (i < code.length) {
29 + let char = code[i];
30 + // Double quoted strings
31 + if (char === '"') {
32 + let string = '';
33 + i++;
34 + do {
35 + if (i > code.length) {
36 + throw Error('Missing a closing quote');
37 + }
38 + char = code[i++];
39 + if (char === '"') {
40 + break;
41 + }
42 + string += char;
43 + } while (true);
44 + tokens.push({type: 'string', value: string});
45 + continue;
46 + }
47 +
48 + // Single quoted strings
49 + if (char === "'") {
50 + let string = '';
51 + i++;
52 + do {
53 + if (i > code.length) {
54 + throw Error('Missing a closing quote');
55 + }
56 + char = code[i++];
57 + if (char === "'") {
58 + break;
59 + }
60 + string += char;
61 + } while (true);
62 + tokens.push({type: 'string', value: string});
63 + continue;
64 + }
65 +
66 + // Whitespace
67 + if (/\s/.test(char)) {
68 + if (char === '\n') {
69 + return tokens;
70 + }
71 + i++;
72 + continue;
73 + }
74 +
75 + const next3 = code.substring(i, i + 3);
76 + if (next3 === '===') {
77 + tokens.push({type: '=='});
78 + i += 3;
79 + continue;
80 + }
81 + if (next3 === '!==') {
82 + tokens.push({type: '!='});
83 + i += 3;
84 + continue;
85 + }
86 +
87 + const next2 = code.substring(i, i + 2);
88 + switch (next2) {
89 + case '&&':
90 + case '||':
91 + case '==':
92 + case '!=':
93 + tokens.push({type: next2});
94 + i += 2;
95 + continue;
96 + }
97 +
98 + switch (char) {
99 + case '(':
100 + case ')':
101 + case '!':
102 + tokens.push({type: char});
103 + i++;
104 + continue;
105 + }
106 +
107 + // Names
108 + const nameRegex = /[a-zA-Z_$][0-9a-zA-Z_$]*/y;
109 + nameRegex.lastIndex = i;
110 + const match = nameRegex.exec(code);
111 + if (match !== null) {
112 + const name = match[0];
113 + switch (name) {
114 + case 'true': {
115 + tokens.push({type: 'boolean', value: true});
116 + break;
117 + }
118 + case 'false': {
119 + tokens.push({type: 'boolean', value: false});
120 + break;
121 + }
122 + default: {
123 + tokens.push({type: 'name', name});
124 + }
125 + }
126 + i += name.length;
127 + continue;
128 + }
129 +
130 + throw Error('Invalid character: ' + char);
131 + }
132 + return tokens;
133 + }
134 +
135 + function parse(code, ctxIdentifier) {
136 + const tokens = tokenize(code);
137 +
138 + let i = 0;
139 + function parseExpression() {
140 + let left = parseBinary();
141 + while (true) {
142 + const token = tokens[i];
143 + if (token !== undefined) {
144 + switch (token.type) {
145 + case '||':
146 + case '&&': {
147 + i++;
148 + const right = parseBinary();
149 + if (right === null) {
150 + throw Error('Missing expression after ' + token.type);
151 + }
152 + left = t.logicalExpression(token.type, left, right);
153 + continue;
154 + }
155 + }
156 + }
157 + break;
158 + }
159 + return left;
160 + }
161 +
162 + function parseBinary() {
163 + let left = parseUnary();
164 + while (true) {
165 + const token = tokens[i];
166 + if (token !== undefined) {
167 + switch (token.type) {
168 + case '==':
169 + case '!=': {
170 + i++;
171 + const right = parseUnary();
172 + if (right === null) {
173 + throw Error('Missing expression after ' + token.type);
174 + }
175 + left = t.binaryExpression(token.type, left, right);
176 + continue;
177 + }
178 + }
179 + }
180 + break;
181 + }
182 + return left;
183 + }
184 +
185 + function parseUnary() {
186 + const token = tokens[i];
187 + if (token !== undefined) {
188 + if (token.type === '!') {
189 + i++;
190 + const argument = parseUnary();
191 + return t.unaryExpression('!', argument);
192 + }
193 + }
194 + return parsePrimary();
195 + }
196 +
197 + function parsePrimary() {
198 + const token = tokens[i];
199 + switch (token.type) {
200 + case 'boolean': {
201 + i++;
202 + return t.booleanLiteral(token.value);
203 + }
204 + case 'name': {
205 + i++;
206 + return t.memberExpression(ctxIdentifier, t.identifier(token.name));
207 + }
208 + case 'string': {
209 + i++;
210 + return t.stringLiteral(token.value);
211 + }
212 + case '(': {
213 + i++;
214 + const expression = parseExpression();
215 + const closingParen = tokens[i];
216 + if (closingParen === undefined || closingParen.type !== ')') {
217 + throw Error('Expected closing )');
218 + }
219 + i++;
220 + return expression;
221 + }
222 + default: {
223 + throw Error('Unexpected token: ' + token.type);
224 + }
225 + }
226 + }
227 +
228 + const program = parseExpression();
229 + if (tokens[i] !== undefined) {
230 + throw Error('Unexpected token');
231 + }
232 + return program;
233 + }
234 +
235 + function buildGateCondition(comments) {
236 + let conditions = null;
237 + for (const line of comments) {
238 + const commentStr = line.value.trim();
239 + if (commentStr.startsWith('@gate ')) {
240 + const code = commentStr.slice(6);
241 + const ctxIdentifier = t.identifier('ctx');
242 + const condition = parse(code, ctxIdentifier);
243 + if (conditions === null) {
244 + conditions = [condition];
245 + } else {
246 + conditions.push(condition);
247 + }
248 + }
249 + }
250 + if (conditions !== null) {
251 + let condition = conditions[0];
252 + for (let i = 1; i < conditions.length; i++) {
253 + const right = conditions[i];
254 + condition = t.logicalExpression('&&', condition, right);
255 + }
256 + return condition;
257 + } else {
258 + return null;
259 + }
260 + }
261 +
262 + return {
263 + name: 'test-gate-pragma',
264 + visitor: {
265 + ExpressionStatement(path) {
266 + const statement = path.node;
267 + const expression = statement.expression;
268 + if (expression.type === 'CallExpression') {
269 + const callee = expression.callee;
270 + switch (callee.type) {
271 + case 'Identifier': {
272 + if (
273 + callee.name === 'test' ||
274 + callee.name === 'it' ||
275 + callee.name === 'fit'
276 + ) {
277 + const comments = statement.leadingComments;
278 + if (comments !== undefined) {
279 + const condition = buildGateCondition(comments);
280 + if (condition !== null) {
281 + callee.name =
282 + callee.name === 'fit' ? '_test_gate_focus' : '_test_gate';
283 + expression.arguments = [
284 + t.arrowFunctionExpression(
285 + [t.identifier('ctx')],
286 + condition
287 + ),
288 + ...expression.arguments,
289 + ];
290 + }
291 + }
292 + }
293 + break;
294 + }
295 + case 'MemberExpression': {
296 + if (
297 + callee.object.type === 'Identifier' &&
298 + (callee.object.name === 'test' ||
299 + callee.object.name === 'it') &&
300 + callee.property.type === 'Identifier' &&
301 + callee.property.name === 'only'
302 + ) {
303 + const comments = statement.leadingComments;
304 + if (comments !== undefined) {
305 + const condition = buildGateCondition(comments);
306 + if (condition !== null) {
307 + statement.expression = t.callExpression(
308 + t.identifier('_test_gate_focus'),
309 + [
310 + t.arrowFunctionExpression(
311 + [t.identifier('ctx')],
312 + condition
313 + ),
314 + ...expression.arguments,
315 + ]
316 + );
317 + }
318 + }
319 + }
320 + break;
321 + }
322 + }
323 + }
324 + return;
325 + },
326 + },
327 + };
328 +}
329 +
330 +module.exports = transform;
scripts/jest/TestFlags.js new
+73
@@ -0,0 +1,73 @@
1 +'use strict';
2 +
3 +// These flags can be in a @gate pragma to declare that a test depends on
4 +// certain conditions. They're like GKs.
5 +//
6 +// Examples:
7 +// // @gate enableBlocksAPI
8 +// test('uses an unstable API', () => {/*...*/})
9 +//
10 +// // @gate __DEV__
11 +// test('only passes in development', () => {/*...*/})
12 +//
13 +// Most flags are defined in ReactFeatureFlags. If it's defined there, you don't
14 +// have to do anything extra here.
15 +//
16 +// There are also flags based on the environment, like __DEV__. Feel free to
17 +// add new flags and aliases below.
18 +//
19 +// You can also combine flags using multiple gates:
20 +//
21 +// // @gate enableBlocksAPI
22 +// // @gate __DEV__
23 +// test('both conditions must pass', () => {/*...*/})
24 +//
25 +// Or using logical operators
26 +// // @gate enableBlocksAPI && __DEV__
27 +// test('both conditions must pass', () => {/*...*/})
28 +//
29 +// Negation also works:
30 +// // @gate !deprecateLegacyContext
31 +// test('uses a deprecated feature', () => {/*...*/})
32 +
33 +// These flags are based on the environment and don't change for the entire
34 +// test run.
35 +const environmentFlags = {
36 + __DEV__,
37 + build: __DEV__ ? 'development' : 'production',
38 + experimental: __EXPERIMENTAL__,
39 + stable: !__EXPERIMENTAL__,
40 +};
41 +
42 +function getTestFlags() {
43 + // These are required on demand because some of our tests mutate them. We try
44 + // not to but there are exceptions.
45 + const featureFlags = require('shared/ReactFeatureFlags');
46 +
47 + // Return a proxy so we can throw if you attempt to access a flag that
48 + // doesn't exist.
49 + return new Proxy(
50 + {
51 + // Feature flag aliases
52 + old: featureFlags.enableNewReconciler === true,
53 + new: featureFlags.enableNewReconciler === true,
54 +
55 + ...featureFlags,
56 + ...environmentFlags,
57 + },
58 + {
59 + get(flags, flagName) {
60 + const flagValue = flags[flagName];
61 + if (typeof flagValue !== 'boolean' && typeof flagName === 'string') {
62 + throw Error(
63 + `Feature flag "${flagName}" does not exist. See TestFlags.js ` +
64 + 'for more details.'
65 + );
66 + }
67 + return flagValue;
68 + },
69 + }
70 + );
71 +}
72 +
73 +exports.getTestFlags = getTestFlags;
scripts/jest/preprocessor.js
+5
@@ -25,6 +25,9 @@ const pathToBabelPluginAsyncToGenerator = require.resolve(
25 const pathToTransformInfiniteLoops = require.resolve(
26 '../babel/transform-prevent-infinite-loops'
27 );
28 +const pathToTransformTestGatePragma = require.resolve(
29 + '../babel/transform-test-gate-pragma'
30 +);
31 const pathToBabelrc = path.join(__dirname, '..', '..', 'babel.config.js');
32 const pathToErrorCodes = require.resolve('../error-codes/codes.json');
33
@@ -42,6 +45,7 @@ const babelOptions = {
45 require.resolve('@babel/plugin-transform-react-jsx-source'),
46
47 pathToTransformInfiniteLoops,
48 + pathToTransformTestGatePragma,
49
50 // This optimization is important for extremely performance-sensitive (e.g. React source).
51 // It's okay to disable it for tests.
@@ -99,6 +103,7 @@ module.exports = {
103 pathToBabelrc,
104 pathToBabelPluginDevWithCode,
105 pathToTransformInfiniteLoops,
106 + pathToTransformTestGatePragma,
107 pathToErrorCodes,
108 ]),
109 };
scripts/jest/setupTests.js
+129 -55
@@ -3,6 +3,7 @@
3 const chalk = require('chalk');
4 const util = require('util');
5 const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError');
6 +const {getTestFlags} = require('./TestFlags');
7
8 if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
9 // Inside the class equivalence tester, we have a custom environment, let's
@@ -66,8 +67,9 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
67 }
68 });
69
69 - ['error', 'warn'].forEach(methodName => {
70 - const unexpectedConsoleCallStacks = [];
70 + // TODO: Consider consolidating this with `yieldValue`. In both cases, tests
71 + // should not be allowed to exit without asserting on the entire log.
72 + const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
73 const newMethod = function(format, ...args) {
74 // Ignore uncaught errors reported by jsdom
75 // and React addendums because they're too noisy.
@@ -87,55 +89,77 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
89
90 console[methodName] = newMethod;
91
90 - env.beforeEach(() => {
91 - unexpectedConsoleCallStacks.length = 0;
92 - });
92 + return newMethod;
93 + };
94
94 - env.afterEach(() => {
95 - if (console[methodName] !== newMethod && !isSpy(console[methodName])) {
96 - throw new Error(
97 - `Test did not tear down console.${methodName} mock properly.`
98 - );
99 - }
95 + const flushUnexpectedConsoleCalls = (
96 + mockMethod,
97 + methodName,
98 + expectedMatcher,
99 + unexpectedConsoleCallStacks
100 + ) => {
101 + if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
102 + throw new Error(
103 + `Test did not tear down console.${methodName} mock properly.`
104 + );
105 + }
106 + if (unexpectedConsoleCallStacks.length > 0) {
107 + const messages = unexpectedConsoleCallStacks.map(
108 + ([stack, message]) =>
109 + `${chalk.red(message)}\n` +
110 + `${stack
111 + .split('\n')
112 + .map(line => chalk.gray(line))
113 + .join('\n')}`
114 + );
115
101 - if (unexpectedConsoleCallStacks.length > 0) {
102 - const messages = unexpectedConsoleCallStacks.map(
103 - ([stack, message]) =>
104 - `${chalk.red(message)}\n` +
105 - `${stack
106 - .split('\n')
107 - .map(line => chalk.gray(line))
108 - .join('\n')}`
109 - );
116 + const message =
117 + `Expected test not to call ${chalk.bold(
118 + `console.${methodName}()`
119 + )}.\n\n` +
120 + 'If the warning is expected, test for it explicitly by:\n' +
121 + `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
122 + `matcher, or...\n` +
123 + `2. Mock it out using ${chalk.bold(
124 + 'spyOnDev'
125 + )}(console, '${methodName}') or ${chalk.bold(
126 + 'spyOnProd'
127 + )}(console, '${methodName}'), and test that the warning occurs.`;
128
111 - let expectedMatcher;
112 - switch (methodName) {
113 - case 'warn':
114 - expectedMatcher = 'toWarnDev';
115 - break;
116 - case 'error':
117 - expectedMatcher = 'toErrorDev';
118 - break;
119 - default:
120 - throw new Error('No matcher for ' + methodName);
121 - }
122 - const message =
123 - `Expected test not to call ${chalk.bold(
124 - `console.${methodName}()`
125 - )}.\n\n` +
126 - 'If the warning is expected, test for it explicitly by:\n' +
127 - `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
128 - `matcher, or...\n` +
129 - `2. Mock it out using ${chalk.bold(
130 - 'spyOnDev'
131 - )}(console, '${methodName}') or ${chalk.bold(
132 - 'spyOnProd'
133 - )}(console, '${methodName}'), and test that the warning occurs.`;
129 + throw new Error(`${message}\n\n${messages.join('\n\n')}`);
130 + }
131 + };
132
135 - throw new Error(`${message}\n\n${messages.join('\n\n')}`);
136 - }
137 - });
138 - });
133 + const unexpectedErrorCallStacks = [];
134 + const unexpectedWarnCallStacks = [];
135 +
136 + const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks);
137 + const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks);
138 +
139 + const flushAllUnexpectedConsoleCalls = () => {
140 + flushUnexpectedConsoleCalls(
141 + errorMethod,
142 + 'error',
143 + 'toErrorDev',
144 + unexpectedErrorCallStacks
145 + );
146 + flushUnexpectedConsoleCalls(
147 + warnMethod,
148 + 'warn',
149 + 'toWarnDev',
150 + unexpectedWarnCallStacks
151 + );
152 + unexpectedErrorCallStacks.length = 0;
153 + unexpectedWarnCallStacks.length = 0;
154 + };
155 +
156 + const resetAllUnexpectedConsoleCalls = () => {
157 + unexpectedErrorCallStacks.length = 0;
158 + unexpectedWarnCallStacks.length = 0;
159 + };
160 +
161 + env.beforeEach(resetAllUnexpectedConsoleCalls);
162 + env.afterEach(flushAllUnexpectedConsoleCalls);
163
164 if (process.env.NODE_ENV === 'production') {
165 // In production, we strip error messages and turn them into codes.
@@ -215,10 +239,10 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
239 global.Error = ErrorProxy;
240 }
241
218 - const expectExperimentalToFail = async callback => {
242 + const expectTestToFail = async (callback, errorMsg) => {
243 if (callback.length > 0) {
244 throw Error(
221 - 'Experimental test helpers do not support `done` callback. Return a ' +
245 + 'Gated test helpers do not support the `done` callback. Return a ' +
246 'promise instead.'
247 );
248 }
@@ -231,15 +255,19 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
255 ) {
256 await maybePromise;
257 }
258 + // Flush unexpected console calls inside the test itself, instead of in
259 + // `afterEach` like we normally do. `afterEach` is too late because if it
260 + // throws, we won't have captured it.
261 + flushAllUnexpectedConsoleCalls();
262 } catch (error) {
263 // Failed as expected
264 + resetAllUnexpectedConsoleCalls();
265 return;
266 }
238 - throw Error(
239 - 'Tests marked experimental are expected to fail, but this one passed.'
240 - );
267 + throw Error(errorMsg);
268 };
269
270 + // TODO: Deprecate these helpers in favor of @gate pragma
271 const it = global.it;
272 const fit = global.fit;
273 const xit = global.xit;
@@ -248,25 +276,71 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
276 fit.experimental = it.only.experimental = it.experimental.only = fit;
277 xit.experimental = it.skip.experimental = it.experimental.skip = xit;
278 } else {
279 + const errorMessage =
280 + 'Tests marked experimental are expected to fail, but this one passed.';
281 it.experimental = (message, callback) => {
282 it(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () =>
253 - expectExperimentalToFail(callback));
283 + expectTestToFail(callback, errorMessage));
284 };
285 fit.experimental = it.only.experimental = it.experimental.only = (
286 message,
287 callback
288 ) => {
289 fit(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () =>
260 - expectExperimentalToFail(callback));
290 + expectTestToFail(callback, errorMessage));
291 };
292 xit.experimental = it.skip.experimental = it.experimental.skip = (
293 message,
294 callback
295 ) => {
296 xit(`[EXPERIMENTAL, SHOULD FAIL] ${message}`, () =>
267 - expectExperimentalToFail(callback));
297 + expectTestToFail(callback, errorMessage));
298 };
299 }
300
301 + const gatedErrorMessage = 'Gated test was expected to fail, but it passed.';
302 + global._test_gate = (gateFn, testName, callback) => {
303 + let shouldPass;
304 + try {
305 + const flags = getTestFlags();
306 + shouldPass = gateFn(flags);
307 + } catch (e) {
308 + test(testName, () => {
309 + throw e;
310 + });
311 + return;
312 + }
313 + if (shouldPass) {
314 + test(testName, callback);
315 + } else {
316 + test(`[GATED, SHOULD FAIL] ${testName}`, () =>
317 + expectTestToFail(callback, gatedErrorMessage));
318 + }
319 + };
320 + global._test_gate_focus = (gateFn, testName, callback) => {
321 + let shouldPass;
322 + try {
323 + const flags = getTestFlags();
324 + shouldPass = gateFn(flags);
325 + } catch (e) {
326 + test.only(testName, () => {
327 + throw e;
328 + });
329 + return;
330 + }
331 + if (shouldPass) {
332 + test.only(testName, callback);
333 + } else {
334 + test.only(`[GATED, SHOULD FAIL] ${testName}`, () =>
335 + expectTestToFail(callback, gatedErrorMessage));
336 + }
337 + };
338 +
339 + // Dynamic version of @gate pragma
340 + global.gate = fn => {
341 + const flags = getTestFlags();
342 + return fn(flags);
343 + };
344 +
345 require('jasmine-check').install();
346 }