main
ts 275 lines 8.36 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 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 }