main
ts 673 lines 19 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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
8 import {Worker} from 'jest-worker';
9 import {cpus} from 'os';
10 import process from 'process';
11 import * as readline from 'readline';
12 import ts from 'typescript';
13 import yargs from 'yargs';
14 import {hideBin} from 'yargs/helpers';
15 import {BABEL_PLUGIN_ROOT, PROJECT_ROOT} from './constants';
16 import {TestFilter, getFixtures} from './fixture-utils';
17 import {
18 TestResult,
19 TestResults,
20 normalizeCodeBlankLines,
21 report,
22 update,
23 } from './reporter';
24 import {
25 RunnerAction,
26 RunnerState,
27 buildRust,
28 makeWatchRunner,
29 watchSrc,
30 } from './runner-watch';
31 import * as runnerWorker from './runner-worker';
32 import {execSync} from 'child_process';
33 import fs from 'fs';
34 import path from 'path';
35 import {minimize, minimizeRustDelta} from './minimize';
36 import {parseInput, parseLanguage, parseSourceType} from './compiler';
37 import {
38 PARSE_CONFIG_PRAGMA_IMPORT,
39 PRINT_HIR_IMPORT,
40 PRINT_REACTIVE_IR_IMPORT,
41 BABEL_PLUGIN_SRC,
42 } from './constants';
43 import chalk from 'chalk';
44
45 const WORKER_PATH = require.resolve('./runner-worker.js');
46 const NUM_WORKERS = cpus().length - 1;
47
48 readline.emitKeypressEvents(process.stdin);
49
50 type TestOptions = {
51 sync: boolean;
52 workerThreads: boolean;
53 watch: boolean;
54 update: boolean;
55 pattern?: string;
56 debug: boolean;
57 verbose: boolean;
58 rust: boolean;
59 };
60
61 type MinimizeOptions = {
62 path: string;
63 update: boolean;
64 rust: boolean;
65 };
66
67 type MinimizeRustDeltaOptions = {
68 path: string;
69 update: boolean;
70 };
71
72 type CompileOptions = {
73 path: string;
74 debug: boolean;
75 };
76
77 async function runTestCommand(opts: TestOptions): Promise<void> {
78 // Rust native module doesn't load in jest-worker child processes,
79 // so force sync mode when using the Rust backend.
80 if (opts.rust) {
81 opts.sync = true;
82 }
83
84 const worker: Worker & typeof runnerWorker = new Worker(WORKER_PATH, {
85 enableWorkerThreads: opts.workerThreads,
86 numWorkers: NUM_WORKERS,
87 }) as any;
88 worker.getStderr().pipe(process.stderr);
89 worker.getStdout().pipe(process.stdout);
90
91 // Check if watch mode should be enabled
92 const shouldWatch = opts.watch;
93
94 if (shouldWatch) {
95 makeWatchRunner(
96 state => onChange(worker, state, opts.sync, opts.verbose, opts.rust),
97 opts.debug,
98 opts.pattern,
99 opts.rust,
100 );
101 if (opts.pattern) {
102 /**
103 * Warm up wormers when in watch mode. Loading the Forget babel plugin
104 * and all of its transitive dependencies takes 1-3s (per worker) on a M1.
105 * As jest-worker dispatches tasks using a round-robin strategy, we can
106 * avoid an additional 1-3s wait on the first num_workers runs by warming
107 * up workers eagerly.
108 */
109 for (let i = 0; i < NUM_WORKERS - 1; i++) {
110 worker.transformFixture(
111 {
112 fixturePath: 'tmp',
113 snapshotPath: './tmp.expect.md',
114 inputPath: './tmp.js',
115 input: `
116 function Foo(props) {
117 return identity(props);
118 }
119 `,
120 snapshot: null,
121 },
122 0,
123 false,
124 false,
125 opts.rust,
126 );
127 }
128 }
129 } else {
130 // Non-watch mode. For simplicity we re-use the same watchSrc() function.
131 // After the first build completes run tests and exit
132 const tsWatch: ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram> =
133 watchSrc(
134 () => {},
135 async (isTypecheckSuccess: boolean) => {
136 let isSuccess = false;
137 if (!isTypecheckSuccess) {
138 console.error(
139 'Found typescript errors in Forget source code, skipping test fixtures.',
140 );
141 } else {
142 try {
143 execSync('yarn build', {cwd: BABEL_PLUGIN_ROOT});
144 console.log('Built compiler successfully with tsup');
145
146 if (opts.rust && !buildRust()) {
147 throw new Error('Failed to build Rust compiler');
148 }
149
150 // Determine which filter to use
151 let testFilter: TestFilter | null = null;
152 if (opts.pattern) {
153 testFilter = {
154 paths: [opts.pattern],
155 };
156 }
157
158 const results = await runFixtures(
159 worker,
160 testFilter,
161 0,
162 opts.debug,
163 false, // no requireSingleFixture in non-watch mode
164 opts.sync,
165 opts.rust,
166 );
167 if (opts.update) {
168 update(results);
169 isSuccess = true;
170 } else {
171 isSuccess = report(results, opts.verbose, opts.rust);
172 }
173 } catch (e) {
174 console.warn('Failed to build compiler with tsup:', e);
175 }
176 }
177 tsWatch?.close();
178 await worker.end();
179 process.exit(isSuccess ? 0 : 1);
180 },
181 );
182 }
183 }
184
185 async function runMinimizeCommand(opts: MinimizeOptions): Promise<void> {
186 // Resolve the input path
187 const inputPath = path.isAbsolute(opts.path)
188 ? opts.path
189 : path.resolve(PROJECT_ROOT, opts.path);
190
191 // Check if file exists
192 if (!fs.existsSync(inputPath)) {
193 console.error(`Error: File not found: ${inputPath}`);
194 process.exit(1);
195 }
196
197 // Read the input file
198 const input = fs.readFileSync(inputPath, 'utf-8');
199 const filename = path.basename(inputPath);
200 const firstLine = input.substring(0, input.indexOf('\n'));
201 const language = parseLanguage(firstLine);
202 const sourceType = parseSourceType(firstLine);
203
204 if (opts.rust && !buildRust()) {
205 console.error('Error: Failed to build Rust compiler');
206 process.exit(1);
207 }
208
209 console.log(
210 `Minimizing: ${inputPath}${opts.rust ? ' (using Rust compiler)' : ''}`,
211 );
212
213 const originalLines = input.split('\n').length;
214
215 // Run the minimization
216 const result = minimize(input, filename, language, sourceType, opts.rust);
217
218 if (result.kind === 'success') {
219 console.log('Could not minimize: the input compiles successfully.');
220 process.exit(0);
221 }
222
223 if (result.kind === 'minimal') {
224 console.log(
225 'Could not minimize: the input fails but is already minimal and cannot be reduced further.',
226 );
227 process.exit(0);
228 }
229
230 // Output the minimized code
231 console.log('--- Minimized Code ---');
232 console.log(result.source);
233
234 const minimizedLines = result.source.split('\n').length;
235 console.log(
236 `\nReduced from ${originalLines} lines to ${minimizedLines} lines`,
237 );
238
239 if (opts.update) {
240 fs.writeFileSync(inputPath, result.source, 'utf-8');
241 console.log(`\nUpdated ${inputPath} with minimized code.`);
242 }
243 }
244
245 async function runMinimizeRustDeltaCommand(
246 opts: MinimizeRustDeltaOptions,
247 ): Promise<void> {
248 const inputPath = path.isAbsolute(opts.path)
249 ? opts.path
250 : path.resolve(PROJECT_ROOT, opts.path);
251
252 if (!fs.existsSync(inputPath)) {
253 console.error(`Error: File not found: ${inputPath}`);
254 process.exit(1);
255 }
256
257 // Build both compilers
258 execSync('yarn build', {cwd: BABEL_PLUGIN_ROOT});
259 if (!buildRust()) {
260 console.error('Error: Failed to build Rust compiler');
261 process.exit(1);
262 }
263
264 const input = fs.readFileSync(inputPath, 'utf-8');
265 const filename = path.basename(inputPath);
266 const firstLine = input.substring(0, input.indexOf('\n'));
267 const language = parseLanguage(firstLine);
268 const sourceType = parseSourceType(firstLine);
269
270 console.log(`Minimizing TS/Rust delta: ${inputPath}`);
271
272 const originalLines = input.split('\n').length;
273
274 const result = minimizeRustDelta(input, filename, language, sourceType);
275
276 if (result.kind === 'no_delta') {
277 console.log(
278 'Could not minimize: TS and Rust compilers produce the same output.',
279 );
280 process.exit(0);
281 }
282
283 if (result.kind === 'minimal') {
284 console.log(
285 'Could not minimize: the delta exists but the input is already minimal.',
286 );
287 process.exit(0);
288 }
289
290 console.log('--- Minimized Code ---');
291 console.log(result.source);
292
293 const minimizedLines = result.source.split('\n').length;
294 console.log(
295 `\nReduced from ${originalLines} lines to ${minimizedLines} lines`,
296 );
297
298 if (opts.update) {
299 fs.writeFileSync(inputPath, result.source, 'utf-8');
300 console.log(`\nUpdated ${inputPath} with minimized code.`);
301 }
302 }
303
304 async function runCompileCommand(opts: CompileOptions): Promise<void> {
305 // Resolve the input path
306 const inputPath = path.isAbsolute(opts.path)
307 ? opts.path
308 : path.resolve(PROJECT_ROOT, opts.path);
309
310 // Check if file exists
311 if (!fs.existsSync(inputPath)) {
312 console.error(`Error: File not found: ${inputPath}`);
313 process.exit(1);
314 }
315
316 // Read the input file
317 const input = fs.readFileSync(inputPath, 'utf-8');
318 const filename = path.basename(inputPath);
319 const firstLine = input.substring(0, input.indexOf('\n'));
320 const language = parseLanguage(firstLine);
321 const sourceType = parseSourceType(firstLine);
322
323 // Import the compiler
324 const importedCompilerPlugin = require(BABEL_PLUGIN_SRC) as Record<
325 string,
326 any
327 >;
328 const BabelPluginReactCompiler = importedCompilerPlugin['default'];
329 const parseConfigPragmaForTests =
330 importedCompilerPlugin[PARSE_CONFIG_PRAGMA_IMPORT];
331 const printFunctionWithOutlined = importedCompilerPlugin[PRINT_HIR_IMPORT];
332 const printReactiveFunctionWithOutlined =
333 importedCompilerPlugin[PRINT_REACTIVE_IR_IMPORT];
334 const EffectEnum = importedCompilerPlugin['Effect'];
335 const ValueKindEnum = importedCompilerPlugin['ValueKind'];
336 const ValueReasonEnum = importedCompilerPlugin['ValueReason'];
337
338 // Setup debug logger
339 let lastLogged: string | null = null;
340 const debugIRLogger = opts.debug
341 ? (value: any) => {
342 let printed: string;
343 switch (value.kind) {
344 case 'hir':
345 printed = printFunctionWithOutlined(value.value);
346 break;
347 case 'reactive':
348 printed = printReactiveFunctionWithOutlined(value.value);
349 break;
350 case 'debug':
351 printed = value.value;
352 break;
353 case 'ast':
354 printed = '(ast)';
355 break;
356 default:
357 printed = String(value);
358 }
359
360 if (printed !== lastLogged) {
361 lastLogged = printed;
362 console.log(`${chalk.green(value.name)}:\n${printed}\n`);
363 } else {
364 console.log(`${chalk.blue(value.name)}: (no change)\n`);
365 }
366 }
367 : () => {};
368
369 // Parse the input
370 let ast;
371 try {
372 ast = parseInput(input, filename, language, sourceType);
373 } catch (e: any) {
374 console.error(`Parse error: ${e.message}`);
375 process.exit(1);
376 }
377
378 // Build plugin options
379 const config = parseConfigPragmaForTests(firstLine, {compilationMode: 'all'});
380 const options = {
381 ...config,
382 environment: {
383 ...config.environment,
384 },
385 logger: {
386 logEvent: () => {},
387 debugLogIRs: debugIRLogger,
388 },
389 enableReanimatedCheck: false,
390 };
391
392 // Compile
393 const {transformFromAstSync} = require('@babel/core');
394 try {
395 const result = transformFromAstSync(ast, input, {
396 filename: '/' + filename,
397 highlightCode: false,
398 retainLines: true,
399 compact: true,
400 plugins: [[BabelPluginReactCompiler, options]],
401 sourceType: 'module',
402 ast: false,
403 cloneInputAst: true,
404 configFile: false,
405 babelrc: false,
406 });
407
408 if (result?.code != null) {
409 // Format the output
410 const prettier = require('prettier');
411 const formatted = await prettier.format(result.code, {
412 semi: true,
413 parser: language === 'typescript' ? 'babel-ts' : 'flow',
414 });
415 console.log(formatted);
416 } else {
417 console.error('Error: No code emitted from compiler');
418 process.exit(1);
419 }
420 } catch (e: any) {
421 console.error(e.message);
422 process.exit(1);
423 }
424 }
425
426 yargs(hideBin(process.argv))
427 .command(
428 ['test', '$0'],
429 'Run compiler tests',
430 yargs => {
431 return yargs
432 .boolean('sync')
433 .describe(
434 'sync',
435 'Run compiler in main thread (instead of using worker threads or subprocesses). Defaults to false.',
436 )
437 .default('sync', false)
438 .boolean('worker-threads')
439 .describe(
440 'worker-threads',
441 'Run compiler in worker threads (instead of subprocesses). Defaults to true.',
442 )
443 .default('worker-threads', true)
444 .boolean('watch')
445 .describe(
446 'watch',
447 'Run compiler in watch mode, re-running after changes',
448 )
449 .alias('w', 'watch')
450 .default('watch', false)
451 .boolean('update')
452 .alias('u', 'update')
453 .describe('update', 'Update fixtures')
454 .default('update', false)
455 .string('pattern')
456 .alias('p', 'pattern')
457 .describe(
458 'pattern',
459 'Optional glob pattern to filter fixtures (e.g., "error.*", "use-memo")',
460 )
461 .boolean('debug')
462 .alias('d', 'debug')
463 .describe('debug', 'Enable debug logging to print HIR for each pass')
464 .default('debug', false)
465 .boolean('verbose')
466 .alias('v', 'verbose')
467 .describe('verbose', 'Print individual test results')
468 .default('verbose', false)
469 .boolean('rust')
470 .describe('rust', 'Use the Rust compiler backend instead of TypeScript')
471 .default('rust', false);
472 },
473 async argv => {
474 await runTestCommand(argv as TestOptions);
475 },
476 )
477 .command(
478 'minimize <path>',
479 'Minimize a test case to reproduce a compiler error',
480 yargs => {
481 return yargs
482 .positional('path', {
483 describe: 'Path to the file to minimize',
484 type: 'string',
485 demandOption: true,
486 })
487 .boolean('update')
488 .alias('u', 'update')
489 .describe(
490 'update',
491 'Update the input file in-place with the minimized version',
492 )
493 .default('update', false)
494 .boolean('rust')
495 .describe('rust', 'Use the Rust compiler backend instead of TypeScript')
496 .default('rust', false);
497 },
498 async argv => {
499 await runMinimizeCommand(argv as unknown as MinimizeOptions);
500 },
501 )
502 .command(
503 'minimize-rust-delta <path>',
504 'Minimize a test case to the smallest code that still produces different output between TS and Rust compilers',
505 yargs => {
506 return yargs
507 .positional('path', {
508 describe: 'Path to the file to minimize',
509 type: 'string',
510 demandOption: true,
511 })
512 .boolean('update')
513 .alias('u', 'update')
514 .describe(
515 'update',
516 'Update the input file in-place with the minimized version',
517 )
518 .default('update', false);
519 },
520 async argv => {
521 await runMinimizeRustDeltaCommand(
522 argv as unknown as MinimizeRustDeltaOptions,
523 );
524 },
525 )
526 .command(
527 'compile <path>',
528 'Compile a file with the React Compiler',
529 yargs => {
530 return yargs
531 .positional('path', {
532 describe: 'Path to the file to compile',
533 type: 'string',
534 demandOption: true,
535 })
536 .boolean('debug')
537 .alias('d', 'debug')
538 .describe('debug', 'Enable debug logging to print HIR for each pass')
539 .default('debug', false);
540 },
541 async argv => {
542 await runCompileCommand(argv as unknown as CompileOptions);
543 },
544 )
545 .help('help')
546 .strict()
547 .demandCommand()
548 .parse();
549
550 /**
551 * Do a test run and return the test results
552 */
553 async function runFixtures(
554 worker: Worker & typeof runnerWorker,
555 filter: TestFilter | null,
556 compilerVersion: number,
557 debug: boolean,
558 requireSingleFixture: boolean,
559 sync: boolean,
560 enableRust: boolean = false,
561 ): Promise<TestResults> {
562 // We could in theory be fancy about tracking the contents of the fixtures
563 // directory via our file subscription, but it's simpler to just re-read
564 // the directory each time.
565 const fixtures = await getFixtures(filter);
566 const isOnlyFixture = filter !== null && fixtures.size === 1;
567 const shouldLog = debug && (!requireSingleFixture || isOnlyFixture);
568
569 let entries: Array<[string, TestResult]>;
570 if (!sync) {
571 // Note: promise.all to ensure parallelism when enabled
572 const work: Array<Promise<[string, TestResult]>> = [];
573 for (const [fixtureName, fixture] of fixtures) {
574 work.push(
575 worker
576 .transformFixture(
577 fixture,
578 compilerVersion,
579 shouldLog,
580 true,
581 enableRust,
582 )
583 .then(result => [fixtureName, result]),
584 );
585 }
586
587 entries = await Promise.all(work);
588 } else {
589 entries = [];
590 for (const [fixtureName, fixture] of fixtures) {
591 let output = await runnerWorker.transformFixture(
592 fixture,
593 compilerVersion,
594 shouldLog,
595 true,
596 enableRust,
597 );
598 entries.push([fixtureName, output]);
599 }
600 }
601
602 return new Map(entries);
603 }
604
605 // Callback to re-run tests after some change
606 async function onChange(
607 worker: Worker & typeof runnerWorker,
608 state: RunnerState,
609 sync: boolean,
610 verbose: boolean,
611 enableRust: boolean = false,
612 ) {
613 const {compilerVersion, isCompilerBuildValid, mode, filter, debug} = state;
614 if (isCompilerBuildValid) {
615 const start = performance.now();
616
617 // console.clear() only works when stdout is connected to a TTY device.
618 // we're currently piping stdout (see main.ts), so let's do a 'hack'
619 console.log('\u001Bc');
620
621 // we don't clear console after this point, since
622 // it may contain debug console logging
623 const results = await runFixtures(
624 worker,
625 mode.filter ? filter : null,
626 compilerVersion,
627 debug,
628 true, // requireSingleFixture in watch mode
629 sync,
630 enableRust,
631 );
632 const end = performance.now();
633
634 // Track fixture status for autocomplete suggestions
635 for (const [basename, result] of results) {
636 const actual =
637 enableRust && result.actual
638 ? normalizeCodeBlankLines(result.actual)
639 : result.actual;
640 const expected =
641 enableRust && result.expected
642 ? normalizeCodeBlankLines(result.expected)
643 : result.expected;
644 const failed = actual !== expected || result.unexpectedError != null;
645 state.fixtureLastRunStatus.set(basename, failed ? 'fail' : 'pass');
646 }
647
648 if (mode.action === RunnerAction.Update) {
649 update(results);
650 state.lastUpdate = end;
651 } else {
652 report(results, verbose, enableRust);
653 }
654 console.log(`Completed in ${Math.floor(end - start)} ms`);
655 } else {
656 console.error(
657 `${mode}: Found errors in Forget source code, skipping test fixtures.`,
658 );
659 }
660 console.log(
661 '\n' +
662 (mode.filter
663 ? `Current mode = FILTER, pattern = "${filter?.paths[0] ?? ''}".`
664 : 'Current mode = NORMAL, run all test fixtures.') +
665 '\nWaiting for input or file changes...\n' +
666 'u - update all fixtures\n' +
667 `d - toggle (turn ${debug ? 'off' : 'on'}) debug logging\n` +
668 'p - enter pattern to filter fixtures\n' +
669 (mode.filter ? 'a - run all tests (exit filter mode)\n' : '') +
670 'q - quit\n' +
671 '[any] - rerun tests\n',
672 );
673 }