231 lines
6.3 KiB
TypeScript
231 lines
6.3 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']);
|
|
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('.');
|
|
}
|