@samitouri / QOS-React-2 / commits / c3b95b0979

[compiler] Improve snap workflow for debugging errors (#35539)

Much nicer workflow for working through errors in the compiler: * Run `yarn snap -w`, oops there are are errors * Hit 'p' to select a fixture => the suggestions populate with recent failures, sorted alphabetically. No need to copy/paste the name of the fixture you want to focus on! * tab/shift-tab to pick one, hit enter to select that one * ...Focus on fixing that test... * 'p' to re-enter the picker. Snap tracks the last state of each fixture and continues to show all tests that failed on their last run, so you can easily move on to the next one. The currently selected test is highlighted, making it easy to move to the next one. * 'a' at any time to run all tests * 'd' at any time to toggle debug output on/off (while focusing on a single test) --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35539). * #35607 * #35298 * #35596 * #35573 * #35595 * __->__ #35539

Joseph Savona committed Jan 23, 2026 at 11:26 UTC c3b95b0979757edb9507a7b0c80fc220a96330b3
2 files changed +186 -14
compiler/packages/snap/src/runner-watch.ts
+178 -14
@@ -9,7 +9,7 @@ import watcher from '@parcel/watcher';
9 import path from 'path';
10 import ts from 'typescript';
11 import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
12 -import {TestFilter} from './fixture-utils';
12 +import {TestFilter, getFixtures} from './fixture-utils';
13 import {execSync} from 'child_process';
14
15 export function watchSrc(
@@ -121,6 +121,12 @@ export type RunnerState = {
121 // Input mode for interactive pattern entry
122 inputMode: 'none' | 'pattern';
123 inputBuffer: string;
124 + // Autocomplete state
125 + allFixtureNames: Array<string>;
126 + matchingFixtures: Array<string>;
127 + selectedIndex: number;
128 + // Track last run status of each fixture (for autocomplete suggestions)
129 + fixtureLastRunStatus: Map<string, 'pass' | 'fail'>;
130 };
131
132 function subscribeFixtures(
@@ -179,46 +185,187 @@ function subscribeTsc(
185 );
186 }
187
188 +/**
189 + * Levenshtein edit distance between two strings
190 + */
191 +function editDistance(a: string, b: string): number {
192 + const m = a.length;
193 + const n = b.length;
194 +
195 + // Create a 2D array for memoization
196 + const dp: number[][] = Array.from({length: m + 1}, () =>
197 + Array(n + 1).fill(0),
198 + );
199 +
200 + // Base cases
201 + for (let i = 0; i <= m; i++) dp[i][0] = i;
202 + for (let j = 0; j <= n; j++) dp[0][j] = j;
203 +
204 + // Fill in the rest
205 + for (let i = 1; i <= m; i++) {
206 + for (let j = 1; j <= n; j++) {
207 + if (a[i - 1] === b[j - 1]) {
208 + dp[i][j] = dp[i - 1][j - 1];
209 + } else {
210 + dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
211 + }
212 + }
213 + }
214 +
215 + return dp[m][n];
216 +}
217 +
218 +function filterFixtures(
219 + allNames: Array<string>,
220 + pattern: string,
221 +): Array<string> {
222 + if (pattern === '') {
223 + return allNames;
224 + }
225 + const lowerPattern = pattern.toLowerCase();
226 + const matches = allNames.filter(name =>
227 + name.toLowerCase().includes(lowerPattern),
228 + );
229 + // Sort by edit distance (lower = better match)
230 + matches.sort((a, b) => {
231 + const distA = editDistance(lowerPattern, a.toLowerCase());
232 + const distB = editDistance(lowerPattern, b.toLowerCase());
233 + return distA - distB;
234 + });
235 + return matches;
236 +}
237 +
238 +const MAX_DISPLAY = 15;
239 +
240 +function renderAutocomplete(state: RunnerState): void {
241 + // Clear terminal
242 + console.log('\u001Bc');
243 +
244 + // Show current input
245 + console.log(`Pattern: ${state.inputBuffer}`);
246 + console.log('');
247 +
248 + // Get current filter pattern if active
249 + const currentFilterPattern =
250 + state.mode.filter && state.filter ? state.filter.paths[0] : null;
251 +
252 + // Show matching fixtures (limit to MAX_DISPLAY)
253 + const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);
254 +
255 + toShow.forEach((name, i) => {
256 + const isSelected = i === state.selectedIndex;
257 + const matchesCurrentFilter =
258 + currentFilterPattern != null &&
259 + name.toLowerCase().includes(currentFilterPattern.toLowerCase());
260 +
261 + let prefix: string;
262 + if (isSelected) {
263 + prefix = '> ';
264 + } else if (matchesCurrentFilter) {
265 + prefix = '* ';
266 + } else {
267 + prefix = ' ';
268 + }
269 + console.log(`${prefix}${name}`);
270 + });
271 +
272 + if (state.matchingFixtures.length > MAX_DISPLAY) {
273 + console.log(
274 + ` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,
275 + );
276 + }
277 +
278 + console.log('');
279 + console.log('↑/↓/Tab navigate | Enter select | Esc cancel');
280 +}
281 +
282 function subscribeKeyEvents(
283 state: RunnerState,
284 onChange: (state: RunnerState) => void,
285 ) {
286 process.stdin.on('keypress', async (str, key) => {
187 - // Handle input mode (pattern entry)
287 + // Handle input mode (pattern entry with autocomplete)
288 if (state.inputMode !== 'none') {
289 if (key.name === 'return') {
190 - // Enter pressed - process input
191 - const pattern = state.inputBuffer.trim();
290 + // Enter pressed - use selected fixture or typed text
291 + let pattern: string;
292 + if (
293 + state.selectedIndex >= 0 &&
294 + state.selectedIndex < state.matchingFixtures.length
295 + ) {
296 + pattern = state.matchingFixtures[state.selectedIndex];
297 + } else {
298 + pattern = state.inputBuffer.trim();
299 + }
300 +
301 state.inputMode = 'none';
302 state.inputBuffer = '';
194 - process.stdout.write('\n');
303 + state.allFixtureNames = [];
304 + state.matchingFixtures = [];
305 + state.selectedIndex = -1;
306
307 if (pattern !== '') {
197 - // Set the pattern as filter
308 state.filter = {paths: [pattern]};
309 state.mode.filter = true;
310 state.mode.action = RunnerAction.Test;
311 onChange(state);
312 }
203 - // If empty, just exit input mode without changes
313 return;
314 } else if (key.name === 'escape') {
315 // Cancel input mode
316 state.inputMode = 'none';
317 state.inputBuffer = '';
209 - process.stdout.write(' (cancelled)\n');
318 + state.allFixtureNames = [];
319 + state.matchingFixtures = [];
320 + state.selectedIndex = -1;
321 + // Redraw normal UI
322 + onChange(state);
323 + return;
324 + } else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
325 + // Navigate up in autocomplete list
326 + if (state.matchingFixtures.length > 0) {
327 + if (state.selectedIndex <= 0) {
328 + state.selectedIndex =
329 + Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
330 + } else {
331 + state.selectedIndex--;
332 + }
333 + renderAutocomplete(state);
334 + }
335 + return;
336 + } else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
337 + // Navigate down in autocomplete list
338 + if (state.matchingFixtures.length > 0) {
339 + const maxIndex =
340 + Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
341 + if (state.selectedIndex >= maxIndex) {
342 + state.selectedIndex = 0;
343 + } else {
344 + state.selectedIndex++;
345 + }
346 + renderAutocomplete(state);
347 + }
348 return;
349 } else if (key.name === 'backspace') {
350 if (state.inputBuffer.length > 0) {
351 state.inputBuffer = state.inputBuffer.slice(0, -1);
214 - // Erase character: backspace, space, backspace
215 - process.stdout.write('\b \b');
352 + state.matchingFixtures = filterFixtures(
353 + state.allFixtureNames,
354 + state.inputBuffer,
355 + );
356 + state.selectedIndex = -1;
357 + renderAutocomplete(state);
358 }
359 return;
360 } else if (str && !key.ctrl && !key.meta) {
219 - // Regular character - accumulate and echo
361 + // Regular character - accumulate, filter, and render
362 state.inputBuffer += str;
221 - process.stdout.write(str);
363 + state.matchingFixtures = filterFixtures(
364 + state.allFixtureNames,
365 + state.inputBuffer,
366 + );
367 + state.selectedIndex = -1;
368 + renderAutocomplete(state);
369 return;
370 }
371 return; // Ignore other keys in input mode
@@ -240,10 +387,23 @@ function subscribeKeyEvents(
387 state.debug = !state.debug;
388 state.mode.action = RunnerAction.Test;
389 } else if (key.name === 'p') {
243 - // p => enter pattern input mode
390 + // p => enter pattern input mode with autocomplete
391 state.inputMode = 'pattern';
392 state.inputBuffer = '';
246 - process.stdout.write('Pattern: ');
393 +
394 + // Load all fixtures for autocomplete
395 + const fixtures = await getFixtures(null);
396 + state.allFixtureNames = Array.from(fixtures.keys()).sort();
397 + // Show failed fixtures first when no pattern entered
398 + const failedFixtures = Array.from(state.fixtureLastRunStatus.entries())
399 + .filter(([_, status]) => status === 'fail')
400 + .map(([name]) => name)
401 + .sort();
402 + state.matchingFixtures =
403 + failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames;
404 + state.selectedIndex = -1;
405 +
406 + renderAutocomplete(state);
407 return; // Don't trigger onChange yet
408 } else {
409 // any other key re-runs tests
@@ -279,6 +439,10 @@ export async function makeWatchRunner(
439 debug: debugMode,
440 inputMode: 'none',
441 inputBuffer: '',
442 + allFixtureNames: [],
443 + matchingFixtures: [],
444 + selectedIndex: -1,
445 + fixtureLastRunStatus: new Map(),
446 };
447
448 subscribeTsc(state, onChange);
compiler/packages/snap/src/runner.ts
+8
@@ -142,6 +142,14 @@ async function onChange(
142 true, // requireSingleFixture in watch mode
143 );
144 const end = performance.now();
145 +
146 + // Track fixture status for autocomplete suggestions
147 + for (const [basename, result] of results) {
148 + const failed =
149 + result.actual !== result.expected || result.unexpectedError != null;
150 + state.fixtureLastRunStatus.set(basename, failed ? 'fail' : 'pass');
151 + }
152 +
153 if (mode.action === RunnerAction.Update) {
154 update(results);
155 state.lastUpdate = end;