Repository: facebook/react
Pull request: [compiler] Improve snap usability


A whole bunch of changes to snap aimed at making it more usable for humans and agents. Here's the new CLI interface:

```
node dist/main.js --help
Options:
      --version         Show version number                            [boolean]
      --sync            Run compiler in main thread (instead of using worker
                        threads or subprocesses). Defaults to false.
                                                      [boolean] [default: false]
      --worker-threads  Run compiler in worker threads (instead of
                        subprocesses). Defaults to true.
                                                       [boolean] [default: true]
      --help            Show help                                      [boolean]
  -w, --watch           Run compiler in watch mode, re-running after changes
                                                                       [boolean]
  -u, --update          Update fixtures                                [boolean]
  -p, --pattern         Optional glob pattern to filter fixtures (e.g.,
                        "error.*", "use-memo")                          [string]
  -d, --debug           Enable debug logging to print HIR for each pass[boolean]
```

Key changes:
* Added abbreviations for common arguments
* No more testfilter.txt! Filtering/debugging works more like Jest, see below.
* The `--debug` flag (`-d`) controls whether to emit debug information. In watch mode, this flag sets the initial debug value, and it can be toggled by pressing the 'd' key while watching.
* The `--pattern` flag (`-p`) sets a filter pattern. In watch mode, this flag sets the initial filter. It can be changed by pressing 'p' and typing a new pattern, or pressing 'a' to switch to running all tests.
* As before, we only actually enable debugging if debug mode is enabled _and_ there is only one test selected.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35537).
* #35607
* #35298
* #35596
* #35573
* #35595
* #35539
* __->__ #35537
* #35523

Changed files:
filename: compiler/packages/snap/src/constants.ts
patch: @@ -26,5 +26,3 @@ export const FIXTURES_PATH = path.join(
   'compiler',
 );
 export const SNAPSHOT_EXTENSION = '.expect.md';
-export const FILTER_FILENAME = 'testfilter.txt';
-export const FILTER_PATH = path.join(PROJECT_ROOT, FILTER_FILENAME);

filename: compiler/packages/snap/src/fixture-utils.ts
patch: @@ -8,7 +8,7 @@
 import fs from 'fs/promises';
 import * as glob from 'glob';
 import path from 'path';
-import {FILTER_PATH, FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
+import {FIXTURES_PATH, SNAPSHOT_EXTENSION} from './constants';
 
 const INPUT_EXTENSIONS = [
   '.js',
@@ -22,19 +22,9 @@ const INPUT_EXTENSIONS = [
 ];
 
 export type TestFilter = {
-  debug: boolean;
   paths: Array<string>;
 };
 
-async function exists(file: string): Promise<boolean> {
-  try {
-    await fs.access(file);
-    return true;
-  } catch {
-    return false;
-  }
-}
-
 function stripExtension(filename: string, extensions: Array<string>): string {
   for (const ext of extensions) {
     if (filename.endsWith(ext)) {
@@ -44,37 +34,6 @@ function stripExtension(filename: string, extensions: Array<string>): string {
   return filename;
 }
 
-export async function readTestFilter(): Promise<TestFilter | null> {
-  if (!(await exists(FILTER_PATH))) {
-    throw new Error(`testfilter file not found at \`${FILTER_PATH}\``);
-  }
-
-  const input = await fs.readFile(FILTER_PATH, 'utf8');
-  const lines = input.trim().split('\n');
-
-  let debug: boolean = false;
-  const line0 = lines[0];
-  if (line0 != null) {
-    // Try to parse pragmas
-    let consumedLine0 = false;
-    if (line0.indexOf('@only') !== -1) {
-      consumedLine0 = true;
-    }
-    if (line0.indexOf('@debug') !== -1) {
-      debug = true;
-      consumedLine0 = true;
-    }
-
-    if (consumedLine0) {
-      lines.shift();
-    }
-  }
-  return {
-    debug,
-    paths: lines.filter(line => !line.trimStart().startsWith('//')),
-  };
-}
-
 export function getBasename(fixture: TestFixture): string {
   return stripExtension(path.basename(fixture.inputPath), INPUT_EXTENSIONS);
 }

filename: compiler/packages/snap/src/runner-watch.ts
patch: @@ -8,8 +8,8 @@
 import watcher from '@parcel/watcher';
 import path from 'path';
 import ts from 'typescript';
-import {FILTER_FILENAME, FIXTURES_PATH, PROJECT_ROOT} from './constants';
-import {TestFilter, readTestFilter} from './fixture-utils';
+import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
+import {TestFilter} from './fixture-utils';
 import {execSync} from 'child_process';
 
 export function watchSrc(
@@ -117,6 +117,10 @@ export type RunnerState = {
   lastUpdate: number;
   mode: RunnerMode;
   filter: TestFilter | null;
+  debug: boolean;
+  // Input mode for interactive pattern entry
+  inputMode: 'none' | 'pattern';
+  inputBuffer: string;
 };
 
 function subscribeFixtures(
@@ -142,26 +146,6 @@ function subscribeFixtures(
   });
 }
 
-function subscribeFilterFile(
-  state: RunnerState,
-  onChange: (state: RunnerState) => void,
-) {
-  watcher.subscribe(PROJECT_ROOT, async (err, events) => {
-    if (err) {
-      console.error(err);
-      process.exit(1);
-    } else if (
-      events.findIndex(event => event.path.includes(FILTER_FILENAME)) !== -1
-    ) {
-      if (state.mode.filter) {
-        state.filter = await readTestFilter();
-        state.mode.action = RunnerAction.Test;
-        onChange(state);
-      }
-    }
-  });
-}
-
 function subscribeTsc(
   state: RunnerState,
   onChange: (state: RunnerState) => void,
@@ -200,15 +184,67 @@ function subscribeKeyEvents(
   onChange: (state: RunnerState) => void,
 ) {
   process.stdin.on('keypress', async (str, key) => {
+    // Handle input mode (pattern entry)
+    if (state.inputMode !== 'none') {
+      if (key.name === 'return') {
+        // Enter pressed - process input
+        const pattern = state.inputBuffer.trim();
+        state.inputMode = 'none';
+        state.inputBuffer = '';
+        process.stdout.write('\n');
+
+        if (pattern !== '') {
+          // Set the pattern as filter
+          state.filter = {paths: [pattern]};
+          state.mode.filter = true;
+          state.mode.action = RunnerAction.Test;
+          onChange(state);
+        }
+        // If empty, just exit input mode without changes
+        return;
+      } else if (key.name === 'escape') {
+        // Cancel input mode
+        state.inputMode = 'none';
+        state.inputBuffer = '';
+        process.stdout.write(' (cancelled)\n');
+        return;
+      } else if (key.name === 'backspace') {
+        if (state.inputBuffer.length > 0) {
+          state.inputBuffer = state.inputBuffer.slice(0, -1);
+          // Erase character: backspace, space, backspace
+          process.stdout.write('\b \b');
+        }
+        return;
+      } else if (str && !key.ctrl && !key.meta) {
+        // Regular character - accumulate and echo
+        state.inputBuffer += str;
+        process.stdout.write(str);
+        return;
+      }
+      return; // Ignore other keys in input mode
+    }
+
+    // Normal mode keypress handling
     if (key.name === 'u') {
       // u => update fixtures
       state.mode.action = RunnerAction.Update;
     } else if (key.name === 'q') {
       process.exit(0);
-    } else if (key.name === 'f') {
-      state.mode.filter = !state.mode.filter;
-      state.filter = state.mode.filter ? await readTestFilter() : null;
+    } else if (key.name === 'a') {
+      // a => exit filter mode and run all tests
+      state.mode.filter = false;
+      state.filter = null;
+      state.mode.action = RunnerAction.Test;
+    } else if (key.name === 'd') {
+      // d => toggle debug logging
+      state.debug = !state.debug;
       state.mode.action = RunnerAction.Test;
+    } else if (key.name === 'p') {
+      // p => enter pattern input mode
+      state.inputMode = 'pattern';
+      state.inputBuffer = '';
+      process.stdout.write('Pattern: ');
+      return; // Don't trigger onChange yet
     } else {
       // any other key re-runs tests
       state.mode.action = RunnerAction.Test;
@@ -219,21 +255,33 @@ function subscribeKeyEvents(
 
 export async function makeWatchRunner(
   onChange: (state: RunnerState) => void,
-  filterMode: boolean,
+  debugMode: boolean,
+  initialPattern?: string,
 ): Promise<void> {
-  const state = {
+  // Determine initial filter state
+  let filter: TestFilter | null = null;
+  let filterEnabled = false;
+
+  if (initialPattern) {
+    filter = {paths: [initialPattern]};
+    filterEnabled = true;
+  }
+
+  const state: RunnerState = {
     compilerVersion: 0,
     isCompilerBuildValid: false,
     lastUpdate: -1,
     mode: {
       action: RunnerAction.Test,
-      filter: filterMode,
+      filter: filterEnabled,
     },
-    filter: filterMode ? await readTestFilter() : null,
+    filter,
+    debug: debugMode,
+    inputMode: 'none',
+    inputBuffer: '',
   };
 
   subscribeTsc(state, onChange);
   subscribeFixtures(state, onChange);
   subscribeKeyEvents(state, onChange);
-  subscribeFilterFile(state, onChange);
 }

filename: compiler/packages/snap/src/runner.ts
patch: @@ -12,8 +12,8 @@ import * as readline from 'readline';
 import ts from 'typescript';
 import yargs from 'yargs';
 import {hideBin} from 'yargs/helpers';
-import {FILTER_PATH, PROJECT_ROOT} from './constants';
-import {TestFilter, getFixtures, readTestFilter} from './fixture-utils';
+import {PROJECT_ROOT} from './constants';
+import {TestFilter, getFixtures} from './fixture-utils';
 import {TestResult, TestResults, report, update} from './reporter';
 import {
   RunnerAction,
@@ -33,9 +33,9 @@ type RunnerOptions = {
   sync: boolean;
   workerThreads: boolean;
   watch: boolean;
-  filter: boolean;
   update: boolean;
   pattern?: string;
+  debug: boolean;
 };
 
 const opts: RunnerOptions = yargs
@@ -59,18 +59,16 @@ const opts: RunnerOptions = yargs
   .alias('u', 'update')
   .describe('update', 'Update fixtures')
   .default('update', false)
-  .boolean('filter')
-  .describe(
-    'filter',
-    'Only run fixtures which match the contents of testfilter.txt',
-  )
-  .default('filter', false)
   .string('pattern')
   .alias('p', 'pattern')
   .describe(
     'pattern',
     'Optional glob pattern to filter fixtures (e.g., "error.*", "use-memo")',
   )
+  .boolean('debug')
+  .alias('d', 'debug')
+  .describe('debug', 'Enable debug logging to print HIR for each pass')
+  .default('debug', false)
   .help('help')
   .strict()
   .parseSync(hideBin(process.argv)) as RunnerOptions;
@@ -82,12 +80,15 @@ async function runFixtures(
   worker: Worker & typeof runnerWorker,
   filter: TestFilter | null,
   compilerVersion: number,
+  debug: boolean,
+  requireSingleFixture: boolean,
 ): Promise<TestResults> {
   // We could in theory be fancy about tracking the contents of the fixtures
   // directory via our file subscription, but it's simpler to just re-read
   // the directory each time.
   const fixtures = await getFixtures(filter);
   const isOnlyFixture = filter !== null && fixtures.size === 1;
+  const shouldLog = debug && (!requireSingleFixture || isOnlyFixture);
 
   let entries: Array<[string, TestResult]>;
   if (!opts.sync) {
@@ -96,12 +97,7 @@ async function runFixtures(
     for (const [fixtureName, fixture] of fixtures) {
       work.push(
         worker
-          .transformFixture(
-            fixture,
-            compilerVersion,
-            (filter?.debug ?? false) && isOnlyFixture,
-            true,
-          )
+          .transformFixture(fixture, compilerVersion, shouldLog, true)
           .then(result => [fixtureName, result]),
       );
     }
@@ -113,7 +109,7 @@ async function runFixtures(
       let output = await runnerWorker.transformFixture(
         fixture,
         compilerVersion,
-        (filter?.debug ?? false) && isOnlyFixture,
+        shouldLog,
         true,
       );
       entries.push([fixtureName, output]);
@@ -128,7 +124,7 @@ async function onChange(
   worker: Worker & typeof runnerWorker,
   state: RunnerState,
 ) {
-  const {compilerVersion, isCompilerBuildValid, mode, filter} = state;
+  const {compilerVersion, isCompilerBuildValid, mode, filter, debug} = state;
   if (isCompilerBuildValid) {
     const start = performance.now();
 
@@ -142,6 +138,8 @@ async function onChange(
       worker,
       mode.filter ? filter : null,
       compilerVersion,
+      debug,
+      true, // requireSingleFixture in watch mode
     );
     const end = performance.now();
     if (mode.action === RunnerAction.Update) {
@@ -159,11 +157,13 @@ async function onChange(
   console.log(
     '\n' +
       (mode.filter
-        ? `Current mode = FILTER, filter test fixtures by "${FILTER_PATH}".`
+        ? `Current mode = FILTER, pattern = "${filter?.paths[0] ?? ''}".`
         : 'Current mode = NORMAL, run all test fixtures.') +
       '\nWaiting for input or file changes...\n' +
       'u     - update all fixtures\n' +
-      `f     - toggle (turn ${mode.filter ? 'off' : 'on'}) filter mode\n` +
+      `d     - toggle (turn ${debug ? 'off' : 'on'}) debug logging\n` +
+      'p     - enter pattern to filter fixtures\n' +
+      (mode.filter ? 'a     - run all tests (exit filter mode)\n' : '') +
       'q     - quit\n' +
       '[any] - rerun tests\n',
   );
@@ -180,15 +180,12 @@ export async function main(opts: RunnerOptions): Promise<void> {
   worker.getStderr().pipe(process.stderr);
   worker.getStdout().pipe(process.stdout);
 
-  // If pattern is provided, force watch mode off and use pattern filter
-  const shouldWatch = opts.watch && opts.pattern == null;
-  if (opts.watch && opts.pattern != null) {
-    console.warn('NOTE: --watch is ignored when a --pattern is supplied');
-  }
+  // Check if watch mode should be enabled
+  const shouldWatch = opts.watch;
 
   if (shouldWatch) {
-    makeWatchRunner(state => onChange(worker, state), opts.filter);
-    if (opts.filter) {
+    makeWatchRunner(state => onChange(worker, state), opts.debug, opts.pattern);
+    if (opts.pattern) {
       /**
        * Warm up wormers when in watch mode. Loading the Forget babel plugin
        * and all of its transitive dependencies takes 1-3s (per worker) on a M1.
@@ -236,14 +233,17 @@ export async function main(opts: RunnerOptions): Promise<void> {
               let testFilter: TestFilter | null = null;
               if (opts.pattern) {
                 testFilter = {
-                  debug: true,
                   paths: [opts.pattern],
                 };
-              } else if (opts.filter) {
-                testFilter = await readTestFilter();
               }
 
-              const results = await runFixtures(worker, testFilter, 0);
+              const results = await runFixtures(
+                worker,
+                testFilter,
+                0,
+                opts.debug,
+                false, // no requireSingleFixture in non-watch mode
+              );
               if (opts.update) {
                 update(results);
                 isSuccess = true;