Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 178 additions & 14 deletions compiler/packages/snap/src/runner-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import watcher from '@parcel/watcher';
import path from 'path';
import ts from 'typescript';
import {FIXTURES_PATH, PROJECT_ROOT} from './constants';
import {TestFilter} from './fixture-utils';
import {TestFilter, getFixtures} from './fixture-utils';
import {execSync} from 'child_process';

export function watchSrc(
Expand Down Expand Up @@ -121,6 +121,12 @@ export type RunnerState = {
// Input mode for interactive pattern entry
inputMode: 'none' | 'pattern';
inputBuffer: string;
// Autocomplete state
allFixtureNames: Array<string>;
matchingFixtures: Array<string>;
selectedIndex: number;
// Track last run status of each fixture (for autocomplete suggestions)
fixtureLastRunStatus: Map<string, 'pass' | 'fail'>;
};

function subscribeFixtures(
Expand Down Expand Up @@ -179,46 +185,187 @@ function subscribeTsc(
);
}

/**
* Levenshtein edit distance between two strings
*/
function editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;

// Create a 2D array for memoization
const dp: number[][] = Array.from({length: m + 1}, () =>
Array(n + 1).fill(0),
);

// Base cases
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;

// Fill in the rest
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}

return dp[m][n];
}

function filterFixtures(
allNames: Array<string>,
pattern: string,
): Array<string> {
if (pattern === '') {
return allNames;
}
const lowerPattern = pattern.toLowerCase();
const matches = allNames.filter(name =>
name.toLowerCase().includes(lowerPattern),
);
// Sort by edit distance (lower = better match)
matches.sort((a, b) => {
const distA = editDistance(lowerPattern, a.toLowerCase());
const distB = editDistance(lowerPattern, b.toLowerCase());
return distA - distB;
});
return matches;
}

const MAX_DISPLAY = 15;

function renderAutocomplete(state: RunnerState): void {
// Clear terminal
console.log('\u001Bc');

// Show current input
console.log(`Pattern: ${state.inputBuffer}`);
console.log('');

// Get current filter pattern if active
const currentFilterPattern =
state.mode.filter && state.filter ? state.filter.paths[0] : null;

// Show matching fixtures (limit to MAX_DISPLAY)
const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);

toShow.forEach((name, i) => {
const isSelected = i === state.selectedIndex;
const matchesCurrentFilter =
currentFilterPattern != null &&
name.toLowerCase().includes(currentFilterPattern.toLowerCase());

let prefix: string;
if (isSelected) {
prefix = '> ';
} else if (matchesCurrentFilter) {
prefix = '* ';
} else {
prefix = ' ';
}
console.log(`${prefix}${name}`);
});

if (state.matchingFixtures.length > MAX_DISPLAY) {
console.log(
` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,
);
}

console.log('');
console.log('↑/↓/Tab navigate | Enter select | Esc cancel');
}

function subscribeKeyEvents(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
process.stdin.on('keypress', async (str, key) => {
// Handle input mode (pattern entry)
// Handle input mode (pattern entry with autocomplete)
if (state.inputMode !== 'none') {
if (key.name === 'return') {
// Enter pressed - process input
const pattern = state.inputBuffer.trim();
// Enter pressed - use selected fixture or typed text
let pattern: string;
if (
state.selectedIndex >= 0 &&
state.selectedIndex < state.matchingFixtures.length
) {
pattern = state.matchingFixtures[state.selectedIndex];
} else {
pattern = state.inputBuffer.trim();
}

state.inputMode = 'none';
state.inputBuffer = '';
process.stdout.write('\n');
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;

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');
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;
// Redraw normal UI
onChange(state);
return;
} else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
// Navigate up in autocomplete list
if (state.matchingFixtures.length > 0) {
if (state.selectedIndex <= 0) {
state.selectedIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
} else {
state.selectedIndex--;
}
renderAutocomplete(state);
}
return;
} else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
// Navigate down in autocomplete list
if (state.matchingFixtures.length > 0) {
const maxIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
if (state.selectedIndex >= maxIndex) {
state.selectedIndex = 0;
} else {
state.selectedIndex++;
}
renderAutocomplete(state);
}
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');
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
}
return;
} else if (str && !key.ctrl && !key.meta) {
// Regular character - accumulate and echo
// Regular character - accumulate, filter, and render
state.inputBuffer += str;
process.stdout.write(str);
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
return;
}
return; // Ignore other keys in input mode
Expand All @@ -240,10 +387,23 @@ function subscribeKeyEvents(
state.debug = !state.debug;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'p') {
// p => enter pattern input mode
// p => enter pattern input mode with autocomplete
state.inputMode = 'pattern';
state.inputBuffer = '';
process.stdout.write('Pattern: ');

// Load all fixtures for autocomplete
const fixtures = await getFixtures(null);
state.allFixtureNames = Array.from(fixtures.keys()).sort();
// Show failed fixtures first when no pattern entered
const failedFixtures = Array.from(state.fixtureLastRunStatus.entries())
.filter(([_, status]) => status === 'fail')
.map(([name]) => name)
.sort();
state.matchingFixtures =
failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames;
state.selectedIndex = -1;

renderAutocomplete(state);
return; // Don't trigger onChange yet
} else {
// any other key re-runs tests
Expand Down Expand Up @@ -279,6 +439,10 @@ export async function makeWatchRunner(
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
allFixtureNames: [],
matchingFixtures: [],
selectedIndex: -1,
fixtureLastRunStatus: new Map(),
};

subscribeTsc(state, onChange);
Expand Down
8 changes: 8 additions & 0 deletions compiler/packages/snap/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ async function onChange(
true, // requireSingleFixture in watch mode
);
const end = performance.now();

// Track fixture status for autocomplete suggestions
for (const [basename, result] of results) {
const failed =
result.actual !== result.expected || result.unexpectedError != null;
state.fixtureLastRunStatus.set(basename, failed ? 'fail' : 'pass');
}

if (mode.action === RunnerAction.Update) {
update(results);
state.lastUpdate = end;
Expand Down
Loading