/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import chalk from 'chalk'; import fs from 'fs'; import invariant from 'invariant'; import {diff} from 'jest-diff'; import path from 'path'; function wrapWithTripleBackticks(s: string, ext: string | null = null): string { return `\`\`\`${ext ?? ''} ${s} \`\`\``; } const SPROUT_SEPARATOR = '\n### Eval output\n'; /** * Normalize blank lines in the ## Code section of a snapshot. * Strips blank lines that appear inside code blocks so that * whitespace-only differences don't cause test failures. */ export function normalizeCodeBlankLines(snapshot: string): string { const codeStart = snapshot.indexOf('## Code\n'); if (codeStart === -1) return snapshot; const codeBlockStart = snapshot.indexOf('```javascript\n', codeStart); if (codeBlockStart === -1) return snapshot; const contentStart = codeBlockStart + '```javascript\n'.length; const codeBlockEnd = snapshot.indexOf('\n```', contentStart); if (codeBlockEnd === -1) return snapshot; const before = snapshot.slice(0, contentStart); const code = snapshot.slice(contentStart, codeBlockEnd); const after = snapshot.slice(codeBlockEnd); const lines = code.split('\n'); const filtered = lines.filter(line => { if (line.trim() === '') return false; // Strip unused var declarations from babel-plugin-idx (e.g., `var _ref2;`) // These are generated by generateUidIdentifier and scope.push() in // babel-plugin-idx, but the actual references may use a different _refN. // The TS and Rust compilers interact differently with Babel's scope UID // counter, producing different _refN numbering for unused declarations. const match = line.match(/^\s*var (_ref\d*);$/); if (match) { const varName = match[1]; // Check if this identifier is used anywhere else in the code const regex = new RegExp('\\b' + varName + '\\b'); const otherLines = lines.filter(l => l !== line); const isUsed = otherLines.some(l => regex.test(l)); if (!isUsed) return false; } return true; }); const normalized = filtered.join('\n'); return before + normalized + after; } export function writeOutputToString( input: string, compilerOutput: string | null, evaluatorOutput: string | null, logs: string | null, errorMessage: string | null, ) { // leading newline intentional let result = ` ## Input ${wrapWithTripleBackticks(input, 'javascript')} `; // trailing newline + space internional if (compilerOutput != null) { result += ` ## Code ${wrapWithTripleBackticks(compilerOutput, 'javascript')} `; } else { result += '\n'; } if (logs != null) { result += ` ## Logs ${wrapWithTripleBackticks(logs, null)} `; } if (errorMessage != null) { result += ` ## Error ${wrapWithTripleBackticks(errorMessage.replace(/^\/.*?:\s/, ''))} \n`; } result += ` `; if (evaluatorOutput != null) { result += SPROUT_SEPARATOR + evaluatorOutput; } return result; } export type TestResult = { actual: string | null; // null == input did not exist expected: string | null; // null == output did not exist outputPath: string; unexpectedError: string | null; }; export type TestResults = Map; /** * Update the fixtures directory given the compilation results */ export async function update(results: TestResults): Promise { let deleted = 0; let updated = 0; let created = 0; const failed = []; for (const [basename, result] of results) { if (result.unexpectedError != null) { console.log( chalk.red.inverse.bold(' FAILED ') + ' ' + chalk.dim(basename), ); failed.push([basename, result.unexpectedError]); } else if (result.actual == null) { // Input was deleted but the expect file still existed, remove it console.log( chalk.red.inverse.bold(' REMOVE ') + ' ' + chalk.dim(basename), ); try { fs.unlinkSync(result.outputPath); console.log(' remove ' + result.outputPath); deleted++; } catch (e) { console.error( '[Snap tester error]: failed to remove ' + result.outputPath, ); failed.push([basename, result.unexpectedError]); } } else if (result.actual !== result.expected) { // Expected output has changed console.log( chalk.blue.inverse.bold(' UPDATE ') + ' ' + chalk.dim(basename), ); try { fs.writeFileSync(result.outputPath, result.actual, 'utf8'); } catch (e) { if (e?.code === 'ENOENT') { // May have failed to create nested dir, so make a directory and retry fs.mkdirSync(path.dirname(result.outputPath), {recursive: true}); fs.writeFileSync(result.outputPath, result.actual, 'utf8'); } } if (result.expected == null) { created++; } else { updated++; } } else { // Expected output is current console.log( chalk.green.inverse.bold(' OKAY ') + ' ' + chalk.dim(basename), ); } } console.log( `${deleted} Deleted, ${created} Created, ${updated} Updated, ${failed.length} Failed`, ); for (const [basename, errorMsg] of failed) { console.log(`${chalk.red.bold('Fail:')} ${basename}\n${errorMsg}`); } } /** * Report test results to the user * @returns boolean indicatig whether all tests passed */ // Fixtures where TS and Rust produce different output. Snapshots reflect Rust // output (source of truth). Skipped when running the TS compiler. const TS_SKIP_FIXTURES: Set = new Set([ // Rust compiles successfully, TS would error. Renamed from error.todo-/error.bug-. 'todo-hoist-type-alias-before-declaration', // Error message/format divergences 'fbt/error.todo-locally-require-fbt', // Minor output difference (TS adds unused runtime import) 'use-no-forget-multiple-with-eslint-suppression', // Cosmetic blank-line/unused-var differences between Rust and TS codegen 'debugger', 'debugger-memoized', 'idx-no-outlining', 'optional-call-with-independently-memoizable-arg', ]); export function report( results: TestResults, verbose: boolean = false, rust: boolean = false, ): boolean { const failures: Array<[string, TestResult]> = []; for (const [basename, result] of results) { if (!rust && TS_SKIP_FIXTURES.has(basename)) { continue; } const actual = rust && result.actual ? normalizeCodeBlankLines(result.actual) : result.actual; const expected = rust && result.expected ? normalizeCodeBlankLines(result.expected) : result.expected; if (actual === expected && result.unexpectedError == null) { if (verbose) { console.log( chalk.green.inverse.bold(' PASS ') + ' ' + chalk.dim(basename), ); } } else { if (verbose) { console.log( chalk.red.inverse.bold(' FAIL ') + ' ' + chalk.dim(basename), ); } failures.push([basename, result]); } } if (failures.length !== 0) { console.log('\n' + chalk.red.bold('Failures:') + '\n'); for (const [basename, result] of failures) { console.log(chalk.red.bold('FAIL:') + ' ' + basename); if (result.unexpectedError != null) { console.log( ` >> Unexpected error during test: \n${result.unexpectedError}`, ); } else { const actual = rust && result.actual ? normalizeCodeBlankLines(result.actual) : result.actual; const expected = rust && result.expected ? normalizeCodeBlankLines(result.expected) : result.expected; if (expected == null) { invariant(actual != null, '[Tester] Internal failure.'); console.log( chalk.red('[ expected fixture output is absent ]') + '\n', ); } else if (actual == null) { invariant(expected != null, '[Tester] Internal failure.'); console.log( chalk.red(`[ fixture input for ${result.outputPath} is absent ]`) + '\n', ); } else { console.log(diff(expected, actual) + '\n'); } } } } console.log( `${results.size} Tests, ${results.size - failures.length} Passed, ${ failures.length } Failed`, ); return failures.length === 0; }