compiler/scripts/test-e2e.ts TYPESCRIPT 559 lines View on github.com → Search inside
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.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.