Repository: facebook/react
Pull request: [compiler][snap] Support pattern of files to test as CLI argument


I've been trying out LLM agents for compiler development, and one thing i found is that the agent naturally wants to run `yarn snap <pattern>` to test a specific fixture, and I want to be able to tell it (directly or in rules/skills) to do this in order to get the debug output from all the compiler passes. Agents can figure out our current testfilter.txt file system but that's just tedious. So here we add support for `yarn snap -p <pattern>`. If you pass in a pattern with an extension, we target that extension specifically. If you pass in a .expect.md file, we look at that specific fixture. And if the pattern doesn't have extensions, we search for `<pattern>{.js,.jsx,.ts,.tsx}`. When patterns are enabled we automatically log as in debug mode (if there is a single match), and disable watch mode.

Open to feedback!


Changed files:
filename: compiler/packages/snap/src/fixture-utils.ts
patch: @@ -44,6 +44,21 @@ function stripExtension(filename: string, extensions: Array<string>): string {
   return filename;
 }
 
+/**
+ * Strip all extensions from a filename
+ * e.g., "foo.expect.md" -> "foo"
+ */
+function stripAllExtensions(filename: string): string {
+  let result = filename;
+  while (true) {
+    const extension = path.extname(result);
+    if (extension === '') {
+      return result;
+    }
+    result = path.basename(result, extension);
+  }
+}
+
 export async function readTestFilter(): Promise<TestFilter | null> {
   if (!(await exists(FILTER_PATH))) {
     throw new Error(`testfilter file not found at \`${FILTER_PATH}\``);
@@ -111,11 +126,25 @@ async function readInputFixtures(
   } else {
     inputFiles = (
       await Promise.all(
-        filter.paths.map(pattern =>
-          glob.glob(`${pattern}{${INPUT_EXTENSIONS.join(',')}}`, {
+        filter.paths.map(pattern => {
+          // If the pattern already has an extension other than .expect.md,
+          // search for the pattern directly. Otherwise, search for the
+          // pattern with the expected input extensions added.
+          // Eg
+          // `alias-while` => search for `alias-while{.js,.jsx,.ts,.tsx}`
+          // `alias-while.js` => search as-is
+          // `alias-while.expect.md` => search for `alias-while{.js,.jsx,.ts,.tsx}`
+          const basename = path.basename(pattern);
+          const basenameWithoutExt = stripAllExtensions(basename);
+          const hasExtension = basename !== basenameWithoutExt;
+          const globPattern =
+            hasExtension && !pattern.endsWith(SNAPSHOT_EXTENSION)
+              ? pattern
+              : `${basenameWithoutExt}{${INPUT_EXTENSIONS.join(',')}}`;
+          return glob.glob(globPattern, {
             cwd: rootDir,
-          }),
-        ),
+          });
+        }),
       )
     ).flat();
   }
@@ -150,11 +179,13 @@ async function readOutputFixtures(
   } else {
     outputFiles = (
       await Promise.all(
-        filter.paths.map(pattern =>
-          glob.glob(`${pattern}${SNAPSHOT_EXTENSION}`, {
+        filter.paths.map(pattern => {
+          // Strip all extensions and find matching .expect.md files
+          const basenameWithoutExt = stripAllExtensions(pattern);
+          return glob.glob(`${basenameWithoutExt}${SNAPSHOT_EXTENSION}`, {
             cwd: rootDir,
-          }),
-        ),
+          });
+        }),
       )
     ).flat();
   }

filename: compiler/packages/snap/src/runner.ts
patch: @@ -35,6 +35,7 @@ type RunnerOptions = {
   watch: boolean;
   filter: boolean;
   update: boolean;
+  pattern?: string;
 };
 
 const opts: RunnerOptions = yargs
@@ -62,9 +63,15 @@ const opts: RunnerOptions = yargs
     '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")',
+  )
   .help('help')
   .strict()
-  .parseSync(hideBin(process.argv));
+  .parseSync(hideBin(process.argv)) as RunnerOptions;
 
 /**
  * Do a test run and return the test results
@@ -171,7 +178,13 @@ export async function main(opts: RunnerOptions): Promise<void> {
   worker.getStderr().pipe(process.stderr);
   worker.getStdout().pipe(process.stdout);
 
-  if (opts.watch) {
+  // 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');
+  }
+
+  if (shouldWatch) {
     makeWatchRunner(state => onChange(worker, state), opts.filter);
     if (opts.filter) {
       /**
@@ -216,7 +229,18 @@ export async function main(opts: RunnerOptions): Promise<void> {
             try {
               execSync('yarn build', {cwd: PROJECT_ROOT});
               console.log('Built compiler successfully with tsup');
-              const testFilter = opts.filter ? await readTestFilter() : null;
+
+              // Determine which filter to use
+              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);
               if (opts.update) {
                 update(results);