1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78/**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 output13 * - babel variant: Rust via Babel plugin (in-process via NAPI) — the14 * production path15 *16 * This complements `yarn snap --rust` by independently comparing the NAPI17 * 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 */2122import * as babel from '@babel/core';23import generate from '@babel/generator';24import {execSync} from 'child_process';25import fs from 'fs';26import path from 'path';27import prettier from 'prettier';2829import {parseConfigPragmaForTests} from '../packages/babel-plugin-react-compiler/src/Utils/TestUtils';3031const REPO_ROOT = path.resolve(__dirname, '../..');3233// --- Parse flags ---34const rawArgs = process.argv.slice(2);35const noColor = rawArgs.includes('--no-color') || !!process.env.NO_COLOR;36const variantIdx = rawArgs.indexOf('--variant');37const variantArg =38 variantIdx >= 0 ? (rawArgs[variantIdx + 1] as 'babel') : null;39const limitIdx = rawArgs.indexOf('--limit');40const limitArg = limitIdx >= 0 ? parseInt(rawArgs[limitIdx + 1], 10) : 50;4142// Extract positional args (strip flags and flag values)43const skipIndices = new Set<number>();44for (const flag of ['--no-color']) {45 const idx = rawArgs.indexOf(flag);46 if (idx >= 0) skipIndices.add(idx);47}48for (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}55const positional = rawArgs.filter((_a, i) => !skipIndices.has(i));5657// --- ANSI colors ---58const useColor = !noColor;59const RED = useColor ? '\x1b[0;31m' : '';60const GREEN = useColor ? '\x1b[0;32m' : '';61const YELLOW = useColor ? '\x1b[0;33m' : '';62const BOLD = useColor ? '\x1b[1m' : '';63const DIM = useColor ? '\x1b[2m' : '';64const RESET = useColor ? '\x1b[0m' : '';6566// --- Fixtures ---67const DEFAULT_FIXTURES_DIR = path.join(68 REPO_ROOT,69 'compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler',70);7172const fixturesPath = positional[0]73 ? path.resolve(positional[0])74 : DEFAULT_FIXTURES_DIR;7576function discoverFixtures(rootPath: string): string[] {77 const stat = fs.statSync(rootPath);78 if (stat.isFile()) {79 return [rootPath];80 }8182 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}100101// --- Build ---102console.log('Building Rust native module...');103try {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}117118// Copy the built dylib as index.node119const NATIVE_DIR = path.join(120 REPO_ROOT,121 'compiler/packages/babel-plugin-react-compiler-rust/native',122);123const NATIVE_NODE_PATH = path.join(NATIVE_DIR, 'index.node');124const TARGET_DIR = path.join(REPO_ROOT, 'compiler/target/debug');125const 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');130131if (!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}137fs.copyFileSync(dylib, NATIVE_NODE_PATH);138139// --- Load plugins ---140const tsPlugin = require('../packages/babel-plugin-react-compiler/src').default;141const rustPlugin =142 require('../packages/babel-plugin-react-compiler-rust/src').default;143144// --- Normalize code for comparison ---145// Reparse with Babel and regenerate with compact:true to erase all146// whitespace/formatting differences, then Prettier for readable output.147async function formatCode(code: string, isFlow: boolean): Promise<string> {148 try {149 const parserPlugins: string[] = isFlow150 ? ['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}168169// --- Compile via Babel plugin ---170type CompileResult = {171 code: string | null;172 error: string | null;173 events: Array<Record<string, unknown>>;174};175176function 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[] = isFlow185 ? ['flow', 'jsx']186 : ['typescript', 'jsx', 'explicitResourceManagement'];187188 const pragmaOpts = parseConfigPragmaForTests(firstLine, {189 compilationMode: 'all',190 });191192 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 };204205 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}223224// --- Event normalization ---225// Strip identifierName (Babel-specific SourceLocation property), sort226// keys for stable comparison, then JSON.stringify. Both the TS plugin and227// the Rust NAPI bridge emit 0-based columns/indices, so no positional228// adjustment is needed.229const STRIP_KEYS = new Set(['identifierName', 'fnLoc']);230231function 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}241242function 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}254255function normalizeEvents(events: Array<Record<string, unknown>>): string {256 return JSON.stringify(sortAndStrip(stripPipelineErrorStack(events)), null, 2);257}258259// --- Simple unified diff ---260function 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}`);271272 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}293294// --- Main ---295type Variant = 'babel';296const ALL_VARIANTS: Variant[] = ['babel'];297const variants: Variant[] = variantArg ? [variantArg] : ALL_VARIANTS;298299const fixtures = discoverFixtures(fixturesPath);300if (fixtures.length === 0) {301 console.error('No fixtures found at', fixturesPath);302 process.exit(1);303}304305interface 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}315316function 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}328329// --- Progress helper ---330function writeProgress(msg: string): void {331 if (process.stderr.isTTY) {332 process.stderr.write(`\r\x1b[K${msg}`);333 }334}335336function clearProgress(): void {337 if (process.stderr.isTTY) {338 process.stderr.write('\r\x1b[K');339 }340}341342// --- Pre-compute TS baselines (shared across variants) ---343interface FixtureInfo {344 fixturePath: string;345 relPath: string;346 source: string;347 firstLine: string;348 isFlow: boolean;349}350351async 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)!);362363 writeProgress(364 ` ${variant}: ${i + 1}/${fixtureInfos.length} (${s.passed} passed, ${365 s.failed366 } failed)`,367 );368369 const variantResult = compileBabel(370 rustPlugin,371 fixturePath,372 source,373 firstLine,374 );375376 const variantCode = await formatCode(variantResult.code ?? '', isFlow);377 const variantEvents = normalizeEvents(variantResult.events);378379 // 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;383384 const codeMatch = tsCode === variantCode || (tsErrored && variantErrored);385 const eventsMatch = tsEvents === variantEvents;386387 // When code doesn't match due to TS error + variant passthrough, check388 // 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 }397398 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 }409410 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}439440(async () => {441 const stats = new Map<Variant, VariantStats>();442 for (const v of variants) {443 stats.set(v, makeStats());444 }445446 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('');456457 // Pre-compute fixture info and TS baselines458 const fixtureInfos: FixtureInfo[] = [];459 const tsBaselines = new Map<string, string>();460 const tsRawEvents = new Map<string, Array<Record<string, unknown>>>();461462 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');469470 writeProgress(` baseline: ${i + 1}/${fixtures.length}`);471472 const tsResult = compileBabel(tsPlugin, fixturePath, source, firstLine);473 const tsCode = await formatCode(tsResult.code ?? '', isFlow);474475 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('');482483 // Run each variant484 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('');497498 // --- Output ---499 if (variantArg) {500 // Single variant mode: show diffs501 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('');510511 for (const failure of s.failures) {512 console.log(`${RED}FAIL${RESET} ${failure.fixture}`);513 console.log(failure.detail);514 console.log('');515 }516517 if (s.failures.length < s.failed) {518 console.log(519 `${DIM} (showing first ${s.failures.length} of ${s.failed} failures)${RESET}`,520 );521 }522523 console.log('---');524 console.log(`${summaryColor}${summary}${RESET}`);525 } else {526 // Summary table mode527 const total = fixtures.length;528529 function fmtCell(passed: number, total: number): string {530 const pct = ((passed / total) * 100).toFixed(1);531 return `${passed}/${total} (${pct}%)`;532 }533534 // Table header535 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}`);542543 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 }554555 // Exit with failure if any variant has failures556 const anyFailed = [...stats.values()].some(s => s.failed > 0);557 process.exit(anyFailed ? 1 : 0);558})();
Findings
✓ No findings reported for this file.