| 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 chalk from 'chalk'; |
| 9 | import fs from 'fs'; |
| 10 | import invariant from 'invariant'; |
| 11 | import {diff} from 'jest-diff'; |
| 12 | import path from 'path'; |
| 13 | |
| 14 | function wrapWithTripleBackticks(s: string, ext: string | null = null): string { |
| 15 | return `\`\`\`${ext ?? ''} |
| 16 | ${s} |
| 17 | \`\`\``; |
| 18 | } |
| 19 | const SPROUT_SEPARATOR = '\n### Eval output\n'; |
| 20 | |
| 21 | /** |
| 22 | * Normalize blank lines in the ## Code section of a snapshot. |
| 23 | * Strips blank lines that appear inside code blocks so that |
| 24 | * whitespace-only differences don't cause test failures. |
| 25 | */ |
| 26 | export function normalizeCodeBlankLines(snapshot: string): string { |
| 27 | const codeStart = snapshot.indexOf('## Code\n'); |
| 28 | if (codeStart === -1) return snapshot; |
| 29 | const codeBlockStart = snapshot.indexOf('```javascript\n', codeStart); |
| 30 | if (codeBlockStart === -1) return snapshot; |
| 31 | const contentStart = codeBlockStart + '```javascript\n'.length; |
| 32 | const codeBlockEnd = snapshot.indexOf('\n```', contentStart); |
| 33 | if (codeBlockEnd === -1) return snapshot; |
| 34 | |
| 35 | const before = snapshot.slice(0, contentStart); |
| 36 | const code = snapshot.slice(contentStart, codeBlockEnd); |
| 37 | const after = snapshot.slice(codeBlockEnd); |
| 38 | |
| 39 | const lines = code.split('\n'); |
| 40 | const filtered = lines.filter(line => { |
| 41 | if (line.trim() === '') return false; |
| 42 | // Strip unused var declarations from babel-plugin-idx (e.g., `var _ref2;`) |
| 43 | // These are generated by generateUidIdentifier and scope.push() in |
| 44 | // babel-plugin-idx, but the actual references may use a different _refN. |
| 45 | // The TS and Rust compilers interact differently with Babel's scope UID |
| 46 | // counter, producing different _refN numbering for unused declarations. |
| 47 | const match = line.match(/^\s*var (_ref\d*);$/); |
| 48 | if (match) { |
| 49 | const varName = match[1]; |
| 50 | // Check if this identifier is used anywhere else in the code |
| 51 | const regex = new RegExp('\\b' + varName + '\\b'); |
| 52 | const otherLines = lines.filter(l => l !== line); |
| 53 | const isUsed = otherLines.some(l => regex.test(l)); |
| 54 | if (!isUsed) return false; |
| 55 | } |
| 56 | return true; |
| 57 | }); |
| 58 | const normalized = filtered.join('\n'); |
| 59 | |
| 60 | return before + normalized + after; |
| 61 | } |
| 62 | |
| 63 | export function writeOutputToString( |
| 64 | input: string, |
| 65 | compilerOutput: string | null, |
| 66 | evaluatorOutput: string | null, |
| 67 | logs: string | null, |
| 68 | errorMessage: string | null, |
| 69 | ) { |
| 70 | // leading newline intentional |
| 71 | let result = ` |
| 72 | ## Input |
| 73 | |
| 74 | ${wrapWithTripleBackticks(input, 'javascript')} |
| 75 | `; // trailing newline + space internional |
| 76 | |
| 77 | if (compilerOutput != null) { |
| 78 | result += ` |
| 79 | ## Code |
| 80 | |
| 81 | ${wrapWithTripleBackticks(compilerOutput, 'javascript')} |
| 82 | `; |
| 83 | } else { |
| 84 | result += '\n'; |
| 85 | } |
| 86 | |
| 87 | if (logs != null) { |
| 88 | result += ` |
| 89 | ## Logs |
| 90 | |
| 91 | ${wrapWithTripleBackticks(logs, null)} |
| 92 | `; |
| 93 | } |
| 94 | |
| 95 | if (errorMessage != null) { |
| 96 | result += ` |
| 97 | ## Error |
| 98 | |
| 99 | ${wrapWithTripleBackticks(errorMessage.replace(/^\/.*?:\s/, ''))} |
| 100 | \n`; |
| 101 | } |
| 102 | result += ` `; |
| 103 | if (evaluatorOutput != null) { |
| 104 | result += SPROUT_SEPARATOR + evaluatorOutput; |
| 105 | } |
| 106 | return result; |
| 107 | } |
| 108 | |
| 109 | export type TestResult = { |
| 110 | actual: string | null; // null == input did not exist |
| 111 | expected: string | null; // null == output did not exist |
| 112 | outputPath: string; |
| 113 | unexpectedError: string | null; |
| 114 | }; |
| 115 | export type TestResults = Map<string, TestResult>; |
| 116 | |
| 117 | /** |
| 118 | * Update the fixtures directory given the compilation results |
| 119 | */ |
| 120 | export async function update(results: TestResults): Promise<void> { |
| 121 | let deleted = 0; |
| 122 | let updated = 0; |
| 123 | let created = 0; |
| 124 | const failed = []; |
| 125 | for (const [basename, result] of results) { |
| 126 | if (result.unexpectedError != null) { |
| 127 | console.log( |
| 128 | chalk.red.inverse.bold(' FAILED ') + ' ' + chalk.dim(basename), |
| 129 | ); |
| 130 | failed.push([basename, result.unexpectedError]); |
| 131 | } else if (result.actual == null) { |
| 132 | // Input was deleted but the expect file still existed, remove it |
| 133 | console.log( |
| 134 | chalk.red.inverse.bold(' REMOVE ') + ' ' + chalk.dim(basename), |
| 135 | ); |
| 136 | try { |
| 137 | fs.unlinkSync(result.outputPath); |
| 138 | console.log(' remove ' + result.outputPath); |
| 139 | deleted++; |
| 140 | } catch (e) { |
| 141 | console.error( |
| 142 | '[Snap tester error]: failed to remove ' + result.outputPath, |
| 143 | ); |
| 144 | failed.push([basename, result.unexpectedError]); |
| 145 | } |
| 146 | } else if (result.actual !== result.expected) { |
| 147 | // Expected output has changed |
| 148 | console.log( |
| 149 | chalk.blue.inverse.bold(' UPDATE ') + ' ' + chalk.dim(basename), |
| 150 | ); |
| 151 | try { |
| 152 | fs.writeFileSync(result.outputPath, result.actual, 'utf8'); |
| 153 | } catch (e) { |
| 154 | if (e?.code === 'ENOENT') { |
| 155 | // May have failed to create nested dir, so make a directory and retry |
| 156 | fs.mkdirSync(path.dirname(result.outputPath), {recursive: true}); |
| 157 | fs.writeFileSync(result.outputPath, result.actual, 'utf8'); |
| 158 | } |
| 159 | } |
| 160 | if (result.expected == null) { |
| 161 | created++; |
| 162 | } else { |
| 163 | updated++; |
| 164 | } |
| 165 | } else { |
| 166 | // Expected output is current |
| 167 | console.log( |
| 168 | chalk.green.inverse.bold(' OKAY ') + ' ' + chalk.dim(basename), |
| 169 | ); |
| 170 | } |
| 171 | } |
| 172 | console.log( |
| 173 | `${deleted} Deleted, ${created} Created, ${updated} Updated, ${failed.length} Failed`, |
| 174 | ); |
| 175 | for (const [basename, errorMsg] of failed) { |
| 176 | console.log(`${chalk.red.bold('Fail:')} ${basename}\n${errorMsg}`); |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Report test results to the user |
| 182 | * @returns boolean indicatig whether all tests passed |
| 183 | */ |
| 184 | // Fixtures where TS and Rust produce different output. Snapshots reflect Rust |
| 185 | // output (source of truth). Skipped when running the TS compiler. |
| 186 | const TS_SKIP_FIXTURES: Set<string> = new Set([ |
| 187 | // Rust compiles successfully, TS would error. Renamed from error.todo-/error.bug-. |
| 188 | 'todo-hoist-type-alias-before-declaration', |
| 189 | // Error message/format divergences |
| 190 | 'fbt/error.todo-locally-require-fbt', |
| 191 | // Minor output difference (TS adds unused runtime import) |
| 192 | 'use-no-forget-multiple-with-eslint-suppression', |
| 193 | // Cosmetic blank-line/unused-var differences between Rust and TS codegen |
| 194 | 'debugger', |
| 195 | 'debugger-memoized', |
| 196 | 'idx-no-outlining', |
| 197 | 'optional-call-with-independently-memoizable-arg', |
| 198 | ]); |
| 199 | export function report( |
| 200 | results: TestResults, |
| 201 | verbose: boolean = false, |
| 202 | rust: boolean = false, |
| 203 | ): boolean { |
| 204 | const failures: Array<[string, TestResult]> = []; |
| 205 | for (const [basename, result] of results) { |
| 206 | if (!rust && TS_SKIP_FIXTURES.has(basename)) { |
| 207 | continue; |
| 208 | } |
| 209 | const actual = |
| 210 | rust && result.actual |
| 211 | ? normalizeCodeBlankLines(result.actual) |
| 212 | : result.actual; |
| 213 | const expected = |
| 214 | rust && result.expected |
| 215 | ? normalizeCodeBlankLines(result.expected) |
| 216 | : result.expected; |
| 217 | if (actual === expected && result.unexpectedError == null) { |
| 218 | if (verbose) { |
| 219 | console.log( |
| 220 | chalk.green.inverse.bold(' PASS ') + ' ' + chalk.dim(basename), |
| 221 | ); |
| 222 | } |
| 223 | } else { |
| 224 | if (verbose) { |
| 225 | console.log( |
| 226 | chalk.red.inverse.bold(' FAIL ') + ' ' + chalk.dim(basename), |
| 227 | ); |
| 228 | } |
| 229 | failures.push([basename, result]); |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | if (failures.length !== 0) { |
| 234 | console.log('\n' + chalk.red.bold('Failures:') + '\n'); |
| 235 | |
| 236 | for (const [basename, result] of failures) { |
| 237 | console.log(chalk.red.bold('FAIL:') + ' ' + basename); |
| 238 | if (result.unexpectedError != null) { |
| 239 | console.log( |
| 240 | ` >> Unexpected error during test: \n${result.unexpectedError}`, |
| 241 | ); |
| 242 | } else { |
| 243 | const actual = |
| 244 | rust && result.actual |
| 245 | ? normalizeCodeBlankLines(result.actual) |
| 246 | : result.actual; |
| 247 | const expected = |
| 248 | rust && result.expected |
| 249 | ? normalizeCodeBlankLines(result.expected) |
| 250 | : result.expected; |
| 251 | if (expected == null) { |
| 252 | invariant(actual != null, '[Tester] Internal failure.'); |
| 253 | console.log( |
| 254 | chalk.red('[ expected fixture output is absent ]') + '\n', |
| 255 | ); |
| 256 | } else if (actual == null) { |
| 257 | invariant(expected != null, '[Tester] Internal failure.'); |
| 258 | console.log( |
| 259 | chalk.red(`[ fixture input for ${result.outputPath} is absent ]`) + |
| 260 | '\n', |
| 261 | ); |
| 262 | } else { |
| 263 | console.log(diff(expected, actual) + '\n'); |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | console.log( |
| 270 | `${results.size} Tests, ${results.size - failures.length} Passed, ${ |
| 271 | failures.length |
| 272 | } Failed`, |
| 273 | ); |
| 274 | return failures.length === 0; |
| 275 | } |