309 lines
		
	
	
		
			8.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			309 lines
		
	
	
		
			8.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * Runtime parser for test file naming convention
 | |
|  * Supports: test.runtime1+runtime2.modifier.ts
 | |
|  * Examples:
 | |
|  *   - test.node.ts
 | |
|  *   - test.chromium.ts
 | |
|  *   - test.node+chromium.ts
 | |
|  *   - test.deno+bun.ts
 | |
|  *   - test.all.ts (runs on all runtimes)
 | |
|  *   - test.chromium.nonci.ts
 | |
|  */
 | |
| 
 | |
| export type Runtime = 'node' | 'chromium' | 'deno' | 'bun';
 | |
| export type Modifier = 'nonci';
 | |
| 
 | |
| export interface ParsedFilename {
 | |
|   baseName: string;
 | |
|   runtimes: Runtime[];
 | |
|   modifiers: Modifier[];
 | |
|   extension: string;
 | |
|   isLegacy: boolean;
 | |
|   original: string;
 | |
| }
 | |
| 
 | |
| export interface ParserConfig {
 | |
|   strictUnknownRuntime?: boolean;  // default: true
 | |
|   defaultRuntimes?: Runtime[];     // default: ['node']
 | |
| }
 | |
| 
 | |
| const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
 | |
| const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']);
 | |
| const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts', 'sh']);
 | |
| const ALL_RUNTIMES: Runtime[] = ['node', 'chromium', 'deno', 'bun'];
 | |
| 
 | |
| // Legacy mappings for backwards compatibility
 | |
| const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = {
 | |
|   browser: ['chromium'],
 | |
|   both: ['node', 'chromium'],
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Parse a test filename to extract runtimes, modifiers, and detect legacy patterns
 | |
|  * Algorithm: Right-to-left token analysis from the extension
 | |
|  */
 | |
| export function parseTestFilename(
 | |
|   filePath: string,
 | |
|   config: ParserConfig = {}
 | |
| ): ParsedFilename {
 | |
|   const strictUnknownRuntime = config.strictUnknownRuntime ?? true;
 | |
|   const defaultRuntimes = config.defaultRuntimes ?? ['node'];
 | |
| 
 | |
|   // Extract just the filename from the path
 | |
|   const fileName = filePath.split('/').pop() || filePath;
 | |
|   const original = fileName;
 | |
| 
 | |
|   // Step 1: Extract and validate extension
 | |
|   const lastDot = fileName.lastIndexOf('.');
 | |
|   if (lastDot === -1) {
 | |
|     throw new Error(`Invalid test file: no extension found in "${fileName}"`);
 | |
|   }
 | |
| 
 | |
|   const extension = fileName.substring(lastDot + 1);
 | |
|   if (!VALID_EXTENSIONS.has(extension)) {
 | |
|     throw new Error(
 | |
|       `Invalid test file extension ".${extension}" in "${fileName}". ` +
 | |
|       `Valid extensions: ${Array.from(VALID_EXTENSIONS).join(', ')}`
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   // Step 2: Split remaining basename by dots
 | |
|   const withoutExtension = fileName.substring(0, lastDot);
 | |
|   const tokens = withoutExtension.split('.');
 | |
| 
 | |
|   if (tokens.length === 0) {
 | |
|     throw new Error(`Invalid test file: empty basename in "${fileName}"`);
 | |
|   }
 | |
| 
 | |
|   // Step 3: Parse from right to left
 | |
|   let isLegacy = false;
 | |
|   const modifiers: Modifier[] = [];
 | |
|   let runtimes: Runtime[] = [];
 | |
|   let runtimeTokenIndex = -1;
 | |
| 
 | |
|   // Scan from right to left
 | |
|   for (let i = tokens.length - 1; i >= 0; i--) {
 | |
|     const token = tokens[i];
 | |
| 
 | |
|     // Check if this is a known modifier
 | |
|     if (KNOWN_MODIFIERS.has(token)) {
 | |
|       modifiers.unshift(token as Modifier);
 | |
|       continue;
 | |
|     }
 | |
| 
 | |
|     // Check if this is a legacy runtime token
 | |
|     if (LEGACY_RUNTIME_MAP[token]) {
 | |
|       isLegacy = true;
 | |
|       runtimes = LEGACY_RUNTIME_MAP[token];
 | |
|       runtimeTokenIndex = i;
 | |
|       break;
 | |
|     }
 | |
| 
 | |
|     // Check if this is a runtime chain (may contain + separators)
 | |
|     if (token.includes('+')) {
 | |
|       const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean);
 | |
|       const validRuntimes: Runtime[] = [];
 | |
|       const invalidRuntimes: string[] = [];
 | |
|       let hasAllKeyword = false;
 | |
| 
 | |
|       for (const candidate of runtimeCandidates) {
 | |
|         if (candidate === 'all') {
 | |
|           hasAllKeyword = true;
 | |
|         } else if (KNOWN_RUNTIMES.has(candidate)) {
 | |
|           // Dedupe: only add if not already in list
 | |
|           if (!validRuntimes.includes(candidate as Runtime)) {
 | |
|             validRuntimes.push(candidate as Runtime);
 | |
|           }
 | |
|         } else {
 | |
|           invalidRuntimes.push(candidate);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // If 'all' keyword is present, expand to all runtimes
 | |
|       if (hasAllKeyword) {
 | |
|         runtimes = [...ALL_RUNTIMES];
 | |
|         runtimeTokenIndex = i;
 | |
|         break;
 | |
|       }
 | |
| 
 | |
|       if (invalidRuntimes.length > 0) {
 | |
|         if (strictUnknownRuntime) {
 | |
|           throw new Error(
 | |
|             `Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
 | |
|             `Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}, all`
 | |
|           );
 | |
|         } else {
 | |
|           console.warn(
 | |
|             `⚠️  Warning: Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
 | |
|             `Defaulting to: ${defaultRuntimes.join('+')}`
 | |
|           );
 | |
|           runtimes = [...defaultRuntimes];
 | |
|           runtimeTokenIndex = i;
 | |
|           break;
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (validRuntimes.length > 0) {
 | |
|         runtimes = validRuntimes;
 | |
|         runtimeTokenIndex = i;
 | |
|         break;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Check if this is the 'all' keyword (expands to all runtimes)
 | |
|     if (token === 'all') {
 | |
|       runtimes = [...ALL_RUNTIMES];
 | |
|       runtimeTokenIndex = i;
 | |
|       break;
 | |
|     }
 | |
| 
 | |
|     // Check if this is a single runtime token
 | |
|     if (KNOWN_RUNTIMES.has(token)) {
 | |
|       runtimes = [token as Runtime];
 | |
|       runtimeTokenIndex = i;
 | |
|       break;
 | |
|     }
 | |
| 
 | |
|     // If we've scanned past modifiers and haven't found a runtime, stop looking
 | |
|     if (modifiers.length > 0) {
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // Step 4: Determine base name
 | |
|   // Everything before the runtime token (if found) is the base name
 | |
|   const baseNameTokens = runtimeTokenIndex >= 0 ? tokens.slice(0, runtimeTokenIndex) : tokens;
 | |
|   const baseName = baseNameTokens.join('.');
 | |
| 
 | |
|   // Step 5: Apply defaults if no runtime was detected
 | |
|   if (runtimes.length === 0) {
 | |
|     runtimes = [...defaultRuntimes];
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     baseName: baseName || 'test',
 | |
|     runtimes,
 | |
|     modifiers,
 | |
|     extension,
 | |
|     isLegacy,
 | |
|     original,
 | |
|   };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Check if a filename uses legacy naming convention
 | |
|  */
 | |
| export function isLegacyFilename(fileName: string): boolean {
 | |
|   const tokens = fileName.split('.');
 | |
|   for (const token of tokens) {
 | |
|     if (LEGACY_RUNTIME_MAP[token]) {
 | |
|       return true;
 | |
|     }
 | |
|   }
 | |
|   return false;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get the suggested new filename for a legacy filename
 | |
|  */
 | |
| export function getLegacyMigrationTarget(fileName: string): string | null {
 | |
|   const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
 | |
| 
 | |
|   if (!parsed.isLegacy) {
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   // Reconstruct filename with new naming
 | |
|   const parts = [parsed.baseName];
 | |
| 
 | |
|   if (parsed.runtimes.length > 0) {
 | |
|     parts.push(parsed.runtimes.join('+'));
 | |
|   }
 | |
| 
 | |
|   if (parsed.modifiers.length > 0) {
 | |
|     parts.push(...parsed.modifiers);
 | |
|   }
 | |
| 
 | |
|   parts.push(parsed.extension);
 | |
| 
 | |
|   return parts.join('.');
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Docker test file information
 | |
|  */
 | |
| export interface DockerTestFileInfo {
 | |
|   baseName: string;
 | |
|   variant: string;
 | |
|   isDockerTest: true;
 | |
|   original: string;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Check if a filename matches the Docker test pattern: *.{variant}.docker.sh
 | |
|  * Examples: test.latest.docker.sh, test.integration.npmci.docker.sh
 | |
|  */
 | |
| export function isDockerTestFile(fileName: string): boolean {
 | |
|   // Must end with .docker.sh
 | |
|   if (!fileName.endsWith('.docker.sh')) {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   // Extract filename from path if needed
 | |
|   const name = fileName.split('/').pop() || fileName;
 | |
| 
 | |
|   // Must have at least 3 parts: [baseName, variant, docker, sh]
 | |
|   const parts = name.split('.');
 | |
|   return parts.length >= 4 && parts[parts.length - 2] === 'docker' && parts[parts.length - 1] === 'sh';
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Parse a Docker test filename to extract variant and base name
 | |
|  * Pattern: test.{baseName}.{variant}.docker.sh
 | |
|  * Examples:
 | |
|  *   - test.latest.docker.sh -> { baseName: 'test', variant: 'latest' }
 | |
|  *   - test.integration.npmci.docker.sh -> { baseName: 'test.integration', variant: 'npmci' }
 | |
|  */
 | |
| export function parseDockerTestFilename(filePath: string): DockerTestFileInfo {
 | |
|   // Extract just the filename from the path
 | |
|   const fileName = filePath.split('/').pop() || filePath;
 | |
|   const original = fileName;
 | |
| 
 | |
|   if (!isDockerTestFile(fileName)) {
 | |
|     throw new Error(`Not a valid Docker test file: "${fileName}". Expected pattern: *.{variant}.docker.sh`);
 | |
|   }
 | |
| 
 | |
|   // Remove .docker.sh suffix
 | |
|   const withoutSuffix = fileName.slice(0, -10); // Remove '.docker.sh'
 | |
|   const tokens = withoutSuffix.split('.');
 | |
| 
 | |
|   if (tokens.length === 0) {
 | |
|     throw new Error(`Invalid Docker test file: empty basename in "${fileName}"`);
 | |
|   }
 | |
| 
 | |
|   // Last token before .docker.sh is the variant
 | |
|   const variant = tokens[tokens.length - 1];
 | |
| 
 | |
|   // Everything else is the base name
 | |
|   const baseName = tokens.slice(0, -1).join('.');
 | |
| 
 | |
|   return {
 | |
|     baseName: baseName || 'test',
 | |
|     variant,
 | |
|     isDockerTest: true,
 | |
|     original,
 | |
|   };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Map a Docker variant to its corresponding Dockerfile path
 | |
|  * "latest" -> "Dockerfile"
 | |
|  * Other variants -> "Dockerfile_{variant}"
 | |
|  */
 | |
| export function mapVariantToDockerfile(variant: string, baseDir: string): string {
 | |
|   if (variant === 'latest') {
 | |
|     return `${baseDir}/Dockerfile`;
 | |
|   }
 | |
|   return `${baseDir}/Dockerfile_${variant}`;
 | |
| }
 |