feat(runtime): Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests
This commit is contained in:
211
ts/tstest.classes.runtime.parser.ts
Normal file
211
ts/tstest.classes.runtime.parser.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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.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']);
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
for (const candidate of runtimeCandidates) {
|
||||
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 (invalidRuntimes.length > 0) {
|
||||
if (strictUnknownRuntime) {
|
||||
throw new Error(
|
||||
`Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
|
||||
`Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}`
|
||||
);
|
||||
} 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 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('.');
|
||||
}
|
Reference in New Issue
Block a user