From 165cf8e429a51b20297071349d8d4958c9237944 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 16 Jan 2026 10:36:11 -0800 Subject: [PATCH] [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. --- compiler/packages/snap/src/constants.ts | 2 - compiler/packages/snap/src/fixture-utils.ts | 43 +------- compiler/packages/snap/src/runner-watch.ts | 108 ++++++++++++++------ compiler/packages/snap/src/runner.ts | 64 ++++++------ 4 files changed, 113 insertions(+), 104 deletions(-) diff --git a/compiler/packages/snap/src/constants.ts b/compiler/packages/snap/src/constants.ts index d1ede2a2f2..066b6b950a 100644 --- a/compiler/packages/snap/src/constants.ts +++ b/compiler/packages/snap/src/constants.ts @@ -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); diff --git a/compiler/packages/snap/src/fixture-utils.ts b/compiler/packages/snap/src/fixture-utils.ts index fae6afef15..29f7d4ddfb 100644 --- a/compiler/packages/snap/src/fixture-utils.ts +++ b/compiler/packages/snap/src/fixture-utils.ts @@ -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; }; -async function exists(file: string): Promise { - try { - await fs.access(file); - return true; - } catch { - return false; - } -} - function stripExtension(filename: string, extensions: Array): string { for (const ext of extensions) { if (filename.endsWith(ext)) { @@ -44,37 +34,6 @@ function stripExtension(filename: string, extensions: Array): string { return filename; } -export async function readTestFilter(): Promise { - 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); } diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 6073fd30f9..06ae998466 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -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 { - 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); } diff --git a/compiler/packages/snap/src/runner.ts b/compiler/packages/snap/src/runner.ts index 478a32d426..53679d8545 100644 --- a/compiler/packages/snap/src/runner.ts +++ b/compiler/packages/snap/src/runner.ts @@ -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 { // 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,16 @@ export async function main(opts: RunnerOptions): Promise { 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 +237,17 @@ export async function main(opts: RunnerOptions): Promise { 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;