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
// 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(
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
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
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);