main
ts 558 lines 16.3 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 /**
9 * End-to-end test script comparing the Rust compiler against the TS reference.
10 *
11 * Runs fixtures through:
12 * - TS baseline (Babel plugin, in-process) — the reference output
13 * - babel variant: Rust via Babel plugin (in-process via NAPI) — the
14 * production path
15 *
16 * This complements `yarn snap --rust` by independently comparing the NAPI
17 * bridge's code output AND its logged events against the TS plugin.
18 *
19 * Usage: npx tsx compiler/scripts/test-e2e.ts [fixtures-path] [--variant babel] [--limit N] [--no-color]
20 */
21
22 import * as babel from '@babel/core';
23 import generate from '@babel/generator';
24 import {execSync} from 'child_process';
25 import fs from 'fs';
26 import path from 'path';
27 import prettier from 'prettier';
28
29 import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils';
30
31 const REPO_ROOT = path.resolve(__dirname, '../..');
32
33 // --- Parse flags ---
34 const rawArgs = process.argv.slice(2);
35 const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR;
36 const variantIdx = rawArgs.indexOf('--variant');
37 const variantArg =
38 variantIdx >= 0 ? (rawArgs[variantIdx + 1] as 'babel') : null;
39 const limitIdx = rawArgs.indexOf('--limit');
40 const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 50;
41
42 // Extract positional args (strip flags and flag values)
43 const skipIndices = new Set<number>();
44 for (const flag of ['--no-color']) {
45 const idx = rawArgs.indexOf(flag);
46 if (idx >= 0) skipIndices.add(idx);
47 }
48 for (const flag of ['--variant', '--limit']) {
49 const idx = rawArgs.indexOf(flag);
50 if (idx >= 0) {
51 skipIndices.add(idx);
52 skipIndices.add(idx + 1);
53 }
54 }
55 const positional = rawArgs.filter((_a, i) => !skipIndices.has(i));
56
57 // --- ANSI colors ---
58 const useColor = !noColor;
59 const RED = useColor ? '\x1b[0;31m' : '';
60 const GREEN = useColor ? '\x1b[0;32m' : '';
61 const YELLOW = useColor ? '\x1b[0;33m' : '';
62 const BOLD = useColor ? '\x1b[1m' : '';
63 const DIM = useColor ? '\x1b[2m' : '';
64 const RESET = useColor ? '\x1b[0m' : '';
65
66 // --- Fixtures ---
67 const DEFAULT_FIXTURES_DIR = path.join(
68 REPO_ROOT,
69 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler',
70 );
71
72 const fixturesPath = positional[0]
73 ? path.resolve(positional[0])
74 : DEFAULT_FIXTURES_DIR;
75
76 function discoverFixtures(rootPath: string): string[] {
77 const stat = fs.statSync(rootPath);
78 if (stat.isFile()) {
79 return [rootPath];
80 }
81
82 const results: string[] = [];
83 function walk(dir: string): void {
84 for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
85 const fullPath = path.join(dir, entry.name);
86 if (entry.isDirectory()) {
87 walk(fullPath);
88 } else if (
89 /\.(js|jsx|ts|tsx)$/.test(entry.name) &&
90 !entry.name.endsWith('.expect.md')
91 ) {
92 results.push(fullPath);
93 }
94 }
95 }
96 walk(rootPath);
97 results.sort();
98 return results;
99 }
100
101 // --- Build ---
102 console.log('Building Rust native module...');
103 try {
104 execSync('~/.cargo/bin/cargo build -p react_compiler_napi', {
105 cwd: path.join(REPO_ROOT, 'compiler/crates'),
106 stdio: ['inherit', 'pipe', 'pipe'],
107 shell: true,
108 });
109 } catch (e: any) {
110 // Show stderr on build failure (includes errors + warnings)
111 if (e.stderr) {
112 process.stderr.write(e.stderr);
113 }
114 console.error(`${RED}ERROR: Failed to build Rust crates.${RESET}`);
115 process.exit(1);
116 }
117
118 // Copy the built dylib as index.node
119 const NATIVE_DIR = path.join(
120 REPO_ROOT,
121 'compiler/packages/babel-plugin-react-compiler-rust/native',
122 );
123 const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');
124 const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug');
125 const dylib = fs.existsSync(
126 path.join(TARGET_DIR, 'libreact_compiler_napi.dylib'),
127 )
128 ? path.join(TARGET_DIR, 'libreact_compiler_napi.dylib')
129 : path.join(TARGET_DIR, 'libreact_compiler_napi.so');
130
131 if (!fs.existsSync(dylib)) {
132 console.error(
133 `${RED}ERROR: Could not find built native module in ${TARGET_DIR}${RESET}`,
134 );
135 process.exit(1);
136 }
137 fs.copyFileSync(dylib, NATIVE_NODE_PATH);
138
139 // --- Load plugins ---
140 const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;
141 const rustPlugin =
142 require('../packages/babel-plugin-react-compiler-rust/src').default;
143
144 // --- Normalize code for comparison ---
145 // Reparse with Babel and regenerate with compact:true to erase all
146 // whitespace/formatting differences, then Prettier for readable output.
147 async function formatCode(code: string, isFlow: boolean): Promise<string> {
148 try {
149 const parserPlugins: string[] = isFlow
150 ? ['flow', 'jsx']
151 : ['typescript', 'jsx', 'explicitResourceManagement'];
152 const ast = babel.parseSync(code, {
153 sourceType: 'module',
154 parserOpts: {plugins: parserPlugins},
155 configFile: false,
156 babelrc: false,
157 });
158 if (!ast) return code;
159 const compact = generate(ast, {compact: true}).code;
160 return await prettier.format(compact, {
161 semi: true,
162 parser: isFlow ? 'flow' : 'babel-ts',
163 });
164 } catch {
165 return code;
166 }
167 }
168
169 // --- Compile via Babel plugin ---
170 type CompileResult = {
171 code: string | null;
172 error: string | null;
173 events: Array<Record<string, unknown>>;
174 };
175
176 function compileBabel(
177 plugin: any,
178 fixturePath: string,
179 source: string,
180 firstLine: string,
181 ): CompileResult {
182 const isFlow = firstLine.includes('@flow');
183 const isScript = firstLine.includes('@script');
184 const parserPlugins: string[] = isFlow
185 ? ['flow', 'jsx']
186 : ['typescript', 'jsx', 'explicitResourceManagement'];
187
188 const pragmaOpts = parseConfigPragmaForTests(firstLine, {
189 compilationMode: 'all',
190 });
191
192 const events: Array<Record<string, unknown>> = [];
193 const pluginOptions = {
194 ...pragmaOpts,
195 compilationMode: 'all' as const,
196 panicThreshold: 'all_errors' as const,
197 logger: {
198 logEvent(_filename: string | null, event: Record<string, unknown>): void {
199 events.push(event);
200 },
201 debugLogIRs(): void {},
202 },
203 };
204
205 try {
206 const result = babel.transformSync(source, {
207 filename: fixturePath,
208 sourceType: isScript ? 'script' : 'module',
209 parserOpts: {plugins: parserPlugins},
210 plugins: [[plugin, pluginOptions]],
211 configFile: false,
212 babelrc: false,
213 });
214 return {code: result?.code ?? null, error: null, events};
215 } catch (e) {
216 return {
217 code: null,
218 error: e instanceof Error ? e.message : String(e),
219 events,
220 };
221 }
222 }
223
224 // --- Event normalization ---
225 // Strip identifierName (Babel-specific SourceLocation property), sort
226 // keys for stable comparison, then JSON.stringify. Both the TS plugin and
227 // the Rust NAPI bridge emit 0-based columns/indices, so no positional
228 // adjustment is needed.
229 const STRIP_KEYS = new Set(['identifierName', 'fnLoc']);
230
231 function sortAndStrip(obj: unknown): unknown {
232 if (obj === null || typeof obj !== 'object') return obj;
233 if (Array.isArray(obj)) return obj.map(sortAndStrip);
234 const sorted: Record<string, unknown> = {};
235 for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
236 if (STRIP_KEYS.has(key)) continue;
237 sorted[key] = sortAndStrip((obj as Record<string, unknown>)[key]);
238 }
239 return sorted;
240 }
241
242 function stripPipelineErrorStack(
243 events: Array<Record<string, unknown>>,
244 ): Array<Record<string, unknown>> {
245 return events.map(event => {
246 if (event.kind !== 'PipelineError') return event;
247 const data = event.data;
248 if (typeof data !== 'string') return event;
249 // Strip JS stack trace: keep only the message (before first "\n at ")
250 const idx = data.indexOf('\n at ');
251 return {...event, data: idx >= 0 ? data.substring(0, idx) : data};
252 });
253 }
254
255 function normalizeEvents(events: Array<Record<string, unknown>>): string {
256 return JSON.stringify(sortAndStrip(stripPipelineErrorStack(events)), null, 2);
257 }
258
259 // --- Simple unified diff ---
260 function unifiedDiff(
261 expected: string,
262 actual: string,
263 leftLabel: string,
264 rightLabel: string,
265 ): string {
266 const expectedLines = expected.split('\n');
267 const actualLines = actual.split('\n');
268 const lines: string[] = [];
269 lines.push(`${RED}--- ${leftLabel}${RESET}`);
270 lines.push(`${GREEN}+++ ${rightLabel}${RESET}`);
271
272 const maxLen = Math.max(expectedLines.length, actualLines.length);
273 let contextStart = -1;
274 for (let i = 0; i < maxLen; i++) {
275 const eLine = i < expectedLines.length ? expectedLines[i] : undefined;
276 const aLine = i < actualLines.length ? actualLines[i] : undefined;
277 if (eLine === aLine) continue;
278 if (contextStart !== i) {
279 lines.push(`${YELLOW}@@ line ${i + 1} @@${RESET}`);
280 }
281 contextStart = i + 1;
282 if (eLine !== undefined && aLine !== undefined) {
283 lines.push(`${RED}-${eLine}${RESET}`);
284 lines.push(`${GREEN}+${aLine}${RESET}`);
285 } else if (eLine !== undefined) {
286 lines.push(`${RED}-${eLine}${RESET}`);
287 } else if (aLine !== undefined) {
288 lines.push(`${GREEN}+${aLine}${RESET}`);
289 }
290 }
291 return lines.join('\n');
292 }
293
294 // --- Main ---
295 type Variant = 'babel';
296 const ALL_VARIANTS: Variant[] = ['babel'];
297 const variants: Variant[] = variantArg ? [variantArg] : ALL_VARIANTS;
298
299 const fixtures = discoverFixtures(fixturesPath);
300 if (fixtures.length === 0) {
301 console.error('No fixtures found at', fixturesPath);
302 process.exit(1);
303 }
304
305 interface VariantStats {
306 passed: number;
307 failed: number;
308 codePassed: number;
309 codeFailed: number;
310 eventsPassed: number;
311 eventsFailed: number;
312 failures: Array<{fixture: string; detail: string}>;
313 failedFixtures: string[];
314 }
315
316 function makeStats(): VariantStats {
317 return {
318 passed: 0,
319 failed: 0,
320 codePassed: 0,
321 codeFailed: 0,
322 eventsPassed: 0,
323 eventsFailed: 0,
324 failures: [],
325 failedFixtures: [],
326 };
327 }
328
329 // --- Progress helper ---
330 function writeProgress(msg: string): void {
331 if (process.stderr.isTTY) {
332 process.stderr.write(`\r\x1b[K${msg}`);
333 }
334 }
335
336 function clearProgress(): void {
337 if (process.stderr.isTTY) {
338 process.stderr.write('\r\x1b[K');
339 }
340 }
341
342 // --- Pre-compute TS baselines (shared across variants) ---
343 interface FixtureInfo {
344 fixturePath: string;
345 relPath: string;
346 source: string;
347 firstLine: string;
348 isFlow: boolean;
349 }
350
351 async function runVariant(
352 variant: Variant,
353 fixtureInfos: FixtureInfo[],
354 tsBaselines: Map<string, string>,
355 tsRawEvents: Map<string, Array<Record<string, unknown>>>,
356 s: VariantStats,
357 ): Promise<void> {
358 for (let i = 0; i < fixtureInfos.length; i++) {
359 const {fixturePath, relPath, source, firstLine, isFlow} = fixtureInfos[i];
360 const tsCode = tsBaselines.get(fixturePath)!;
361 const tsEvents = normalizeEvents(tsRawEvents.get(fixturePath)!);
362
363 writeProgress(
364 ` ${variant}: ${i + 1}/${fixtureInfos.length} (${s.passed} passed, ${
365 s.failed
366 } failed)`,
367 );
368
369 const variantResult = compileBabel(
370 rustPlugin,
371 fixturePath,
372 source,
373 firstLine,
374 );
375
376 const variantCode = await formatCode(variantResult.code ?? '', isFlow);
377 const variantEvents = normalizeEvents(variantResult.events);
378
379 // When both TS and the variant error (produce empty/no output), count as pass.
380 const tsErrored = tsCode.trim() === '';
381 const variantErrored =
382 variantCode.trim() === '' || variantResult.error != null;
383
384 const codeMatch = tsCode === variantCode || (tsErrored && variantErrored);
385 const eventsMatch = tsEvents === variantEvents;
386
387 // When code doesn't match due to TS error + variant passthrough, check
388 // if the variant output is just uncompiled source (no memoization).
389 let codePassthrough = false;
390 if (!codeMatch && tsErrored && variantCode.trim() !== '') {
391 const variantHasMemoization =
392 variantCode.includes('_c(') || variantCode.includes('useMemoCache');
393 if (!variantHasMemoization) {
394 codePassthrough = true;
395 }
396 }
397
398 const codeOk = codeMatch || codePassthrough;
399 if (codeOk) {
400 s.codePassed++;
401 } else {
402 s.codeFailed++;
403 }
404 if (eventsMatch) {
405 s.eventsPassed++;
406 } else {
407 s.eventsFailed++;
408 }
409
410 if (codeOk && eventsMatch) {
411 s.passed++;
412 } else {
413 s.failed++;
414 s.failedFixtures.push(relPath);
415 if (limitArg === 0 || s.failures.length < limitArg) {
416 const details: string[] = [];
417 if (!codeOk) {
418 details.push(unifiedDiff(tsCode, variantCode, 'TypeScript', variant));
419 }
420 if (!eventsMatch) {
421 details.push(
422 unifiedDiff(
423 tsEvents,
424 variantEvents,
425 'TS events',
426 variant + ' events',
427 ),
428 );
429 }
430 s.failures.push({
431 fixture: relPath,
432 detail: details.join('\n\n'),
433 });
434 }
435 }
436 }
437 clearProgress();
438 }
439
440 (async () => {
441 const stats = new Map<Variant, VariantStats>();
442 for (const v of variants) {
443 stats.set(v, makeStats());
444 }
445
446 if (variantArg) {
447 console.log(
448 `Testing ${BOLD}${fixtures.length}${RESET} fixtures: TS baseline vs ${BOLD}${variantArg}${RESET}`,
449 );
450 } else {
451 console.log(
452 `Testing ${BOLD}${fixtures.length}${RESET} fixtures across all variants`,
453 );
454 }
455 console.log('');
456
457 // Pre-compute fixture info and TS baselines
458 const fixtureInfos: FixtureInfo[] = [];
459 const tsBaselines = new Map<string, string>();
460 const tsRawEvents = new Map<string, Array<Record<string, unknown>>>();
461
462 console.log('Computing TS baselines...');
463 for (let i = 0; i < fixtures.length; i++) {
464 const fixturePath = fixtures[i];
465 const relPath = path.relative(REPO_ROOT, fixturePath);
466 const source = fs.readFileSync(fixturePath, 'utf8');
467 const firstLine = source.substring(0, source.indexOf('\n'));
468 const isFlow = firstLine.includes('@flow');
469
470 writeProgress(` baseline: ${i + 1}/${fixtures.length}`);
471
472 const tsResult = compileBabel(tsPlugin, fixturePath, source, firstLine);
473 const tsCode = await formatCode(tsResult.code ?? '', isFlow);
474
475 fixtureInfos.push({fixturePath, relPath, source, firstLine, isFlow});
476 tsBaselines.set(fixturePath, tsCode);
477 tsRawEvents.set(fixturePath, tsResult.events);
478 }
479 clearProgress();
480 console.log(`Computed ${fixtures.length} baselines.`);
481 console.log('');
482
483 // Run each variant
484 for (const variant of variants) {
485 console.log(`Running ${BOLD}${variant}${RESET} variant...`);
486 await runVariant(
487 variant,
488 fixtureInfos,
489 tsBaselines,
490 tsRawEvents,
491 stats.get(variant)!,
492 );
493 const s = stats.get(variant)!;
494 console.log(` ${s.passed} passed, ${s.failed} failed`);
495 }
496 console.log('');
497
498 // --- Output ---
499 if (variantArg) {
500 // Single variant mode: show diffs
501 const s = stats.get(variantArg)!;
502 const total = fixtures.length;
503 const summaryColor = s.failed === 0 ? GREEN : RED;
504 const summary =
505 `Code: ${s.codePassed}/${total} passed ` +
506 `Events: ${s.eventsPassed}/${total} passed ` +
507 `Total: ${s.passed}/${total} passed`;
508 console.log(`${summaryColor}${summary}${RESET}`);
509 console.log('');
510
511 for (const failure of s.failures) {
512 console.log(`${RED}FAIL${RESET} ${failure.fixture}`);
513 console.log(failure.detail);
514 console.log('');
515 }
516
517 if (s.failures.length < s.failed) {
518 console.log(
519 `${DIM} (showing first ${s.failures.length} of ${s.failed} failures)${RESET}`,
520 );
521 }
522
523 console.log('---');
524 console.log(`${summaryColor}${summary}${RESET}`);
525 } else {
526 // Summary table mode
527 const total = fixtures.length;
528
529 function fmtCell(passed: number, total: number): string {
530 const pct = ((passed / total) * 100).toFixed(1);
531 return `${passed}/${total} (${pct}%)`;
532 }
533
534 // Table header
535 const colW = 22;
536 const hdr =
537 `${'Variant'.padEnd(10)} ` +
538 `${'Code'.padEnd(colW)} ` +
539 `${'Events'.padEnd(colW)} ` +
540 `${'Total'.padEnd(colW)}`;
541 console.log(`${BOLD}${hdr}${RESET}`);
542
543 for (const variant of ALL_VARIANTS) {
544 const s = stats.get(variant)!;
545 const line =
546 `${variant.padEnd(10)} ` +
547 `${fmtCell(s.codePassed, total).padEnd(colW)} ` +
548 `${fmtCell(s.eventsPassed, total).padEnd(colW)} ` +
549 `${fmtCell(s.passed, total)}`;
550 const color = s.failed === 0 ? GREEN : s.passed === 0 ? RED : YELLOW;
551 console.log(`${color}${line}${RESET}`);
552 }
553 }
554
555 // Exit with failure if any variant has failures
556 const anyFailed = [...stats.values()].some(s => s.failed > 0);
557 process.exit(anyFailed ? 1 : 0);
558 })();