@dkoul/auto-testid-core
Version:
Core AST parsing and transformation logic for React and Vue.js attribute generation
333 lines • 13.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.idGenerator = exports.IDGenerator = void 0;
const logger_1 = require("../utils/logger");
const validation_1 = require("../utils/validation");
class IDGenerator {
constructor() {
this.logger = new logger_1.Logger('IDGenerator');
// Common words that provide semantic meaning
this.SEMANTIC_KEYWORDS = {
actions: ['click', 'submit', 'cancel', 'close', 'open', 'save', 'delete', 'edit', 'add', 'remove'],
navigation: ['nav', 'menu', 'link', 'breadcrumb', 'tab', 'page', 'home', 'back', 'next'],
forms: ['form', 'input', 'field', 'select', 'option', 'checkbox', 'radio', 'textarea', 'label'],
content: ['title', 'heading', 'text', 'content', 'description', 'summary', 'detail'],
layout: ['header', 'footer', 'sidebar', 'main', 'container', 'wrapper', 'section'],
status: ['success', 'error', 'warning', 'info', 'loading', 'disabled', 'active', 'selected'],
};
// Element type mappings for better semantic IDs
this.ELEMENT_MAPPINGS = {
'button': 'btn',
'input': 'input',
'select': 'select',
'textarea': 'textarea',
'form': 'form',
'div': 'container',
'span': 'text',
'img': 'image',
'a': 'link',
'h1': 'heading',
'h2': 'heading',
'h3': 'heading',
'h4': 'heading',
'h5': 'heading',
'h6': 'heading',
'p': 'paragraph',
'ul': 'list',
'ol': 'list',
'li': 'item',
'table': 'table',
'tr': 'row',
'td': 'cell',
'th': 'header',
};
}
generate(element, context) {
this.logger.debug(`Generating ID for ${element.tag} element`);
// Build semantic components for the ID
const components = [];
// 1. Add context component if available
if (context.component) {
components.push(this.sanitizeComponent(context.component));
}
// 2. Analyze element for semantic meaning
const semanticParts = this.extractSemanticParts(element);
components.push(...semanticParts);
// 3. Add element type
const elementType = this.getElementType(element);
if (elementType && !components.includes(elementType)) {
components.push(elementType);
}
// 4. Generate base ID
let baseId = this.combineComponents(components, context.namingStrategy);
// 5. Apply prefix if specified
if (context.prefix) {
baseId = this.applyPrefix(baseId, context.prefix, context.namingStrategy);
}
// 6. Ensure uniqueness
const uniqueId = this.resolveConflicts(baseId, context.existingIds);
// 7. Validate and sanitize final ID
const finalId = validation_1.ValidationUtils.sanitizeTestId(uniqueId, 50);
this.logger.debug(`Generated ID: ${finalId} for ${element.tag}`);
return finalId;
}
validateUniqueness(id, scope) {
return !scope.has(id);
}
resolveConflicts(id, existingIds) {
if (!existingIds.has(id)) {
return id;
}
this.logger.debug(`Resolving conflict for ID: ${id}`);
// Try different strategies to resolve conflicts
let attempts = 0;
let resolvedId = id;
while (existingIds.has(resolvedId) && attempts < 100) {
attempts++;
// Strategy 1: Append incremental number
resolvedId = `${id}-${attempts}`;
// Strategy 2: For very long IDs, try truncation with suffix
if (attempts > 10 && id.length > 30) {
const truncated = id.substring(0, 25);
resolvedId = `${truncated}-${attempts}`;
}
}
if (existingIds.has(resolvedId)) {
// Fallback: Generate a unique ID with timestamp
resolvedId = `${id}-${Date.now().toString(36)}`;
}
this.logger.debug(`Resolved conflict: ${id} -> ${resolvedId}`);
return resolvedId;
}
extractSemanticParts(element) {
const parts = [];
// 1. Analyze attributes for semantic meaning
const meaningfulAttrs = this.extractMeaningfulAttributes(element);
parts.push(...meaningfulAttrs);
// 2. Analyze content for keywords
if (element.content) {
const contentKeywords = this.extractContentKeywords(element.content);
parts.push(...contentKeywords);
}
// 3. Look for ARIA attributes
const ariaSemantics = this.extractAriaSemantics(element);
parts.push(...ariaSemantics);
// 4. Analyze class names for semantic patterns
const classSemantics = this.extractClassSemantics(element);
parts.push(...classSemantics);
return parts.filter(part => part.length > 0);
}
extractMeaningfulAttributes(element) {
const parts = [];
const attrs = element.attributes || {};
// Common meaningful attributes
const meaningfulAttrNames = ['name', 'id', 'type', 'role', 'title', 'alt', 'placeholder'];
meaningfulAttrNames.forEach(attrName => {
const value = attrs[attrName];
if (value && typeof value === 'string') {
const keywords = this.extractKeywords(value);
parts.push(...keywords);
}
});
return parts;
}
extractContentKeywords(content) {
if (!content || content.trim().length === 0) {
return [];
}
// Extract keywords from text content
const words = content
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 2 && word.length < 15);
// Prioritize semantic keywords
const semanticWords = [];
const allSemanticKeywords = Object.values(this.SEMANTIC_KEYWORDS).flat();
words.forEach(word => {
if (allSemanticKeywords.includes(word)) {
semanticWords.push(word);
}
});
// If no semantic words found, use first few meaningful words
if (semanticWords.length === 0 && words.length > 0) {
return words.slice(0, 2); // Take first 2 words
}
return semanticWords.slice(0, 3); // Limit to 3 semantic words
}
extractAriaSemantics(element) {
const parts = [];
const attrs = element.attributes || {};
// ARIA attributes that provide semantic meaning
const ariaAttrs = [
'aria-label',
'aria-describedby',
'aria-labelledby',
'role',
];
ariaAttrs.forEach(attrName => {
const value = attrs[attrName];
if (value && typeof value === 'string') {
const keywords = this.extractKeywords(value);
parts.push(...keywords);
}
});
return parts;
}
extractClassSemantics(element) {
const className = element.attributes?.className || element.attributes?.class || '';
if (!className) {
return [];
}
const classNames = className.split(/\s+/);
const semanticClasses = [];
classNames.forEach(cls => {
// Look for BEM-style classes or semantic patterns
const keywords = this.extractKeywords(cls);
semanticClasses.push(...keywords);
});
return semanticClasses.slice(0, 2); // Limit to avoid overly long IDs
}
extractKeywords(text) {
if (!text)
return [];
// Split by common separators and extract meaningful parts
const words = text
.toLowerCase()
.replace(/[-_]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 1);
const allSemanticKeywords = Object.values(this.SEMANTIC_KEYWORDS).flat();
const semanticWords = words.filter(word => allSemanticKeywords.includes(word));
// Return semantic words first, then other meaningful words
return [...semanticWords, ...words.filter(w => !semanticWords.includes(w))].slice(0, 3);
}
getElementType(element) {
const tag = element.tag.toLowerCase();
// Use mapping if available
if (this.ELEMENT_MAPPINGS[tag]) {
return this.ELEMENT_MAPPINGS[tag];
}
// For custom elements or unknown tags
if (tag.includes('-')) {
// Custom element - use the last part
const parts = tag.split('-');
return parts[parts.length - 1];
}
// Return the tag itself for unmapped elements
return tag;
}
sanitizeComponent(component) {
return component
.toLowerCase()
.replace(/component$/i, '') // Remove "Component" suffix
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
combineComponents(components, strategy) {
if (components.length === 0) {
return 'element';
}
// Remove duplicates while preserving order
const uniqueComponents = [...new Set(components)];
switch (strategy.type) {
case 'kebab-case':
return uniqueComponents.join('-');
case 'camelCase':
return uniqueComponents.reduce((result, component, index) => {
if (index === 0) {
return component.toLowerCase();
}
return result + component.charAt(0).toUpperCase() + component.slice(1).toLowerCase();
}, '');
case 'snake_case':
return uniqueComponents.join('_');
case 'custom':
if (strategy.customTransform) {
return strategy.customTransform(uniqueComponents.join('-'));
}
return uniqueComponents.join('-');
default:
return uniqueComponents.join('-');
}
}
applyPrefix(id, prefix, strategy) {
const separator = this.getSeparator(strategy.type);
return `${prefix}${separator}${id}`;
}
getSeparator(strategyType) {
switch (strategyType) {
case 'kebab-case':
return '-';
case 'snake_case':
return '_';
case 'camelCase':
return '';
default:
return '-';
}
}
// Utility method to analyze element priority for ID generation
getPriority(element) {
let priority = 0;
// Interactive elements get higher priority
const interactiveElements = ['button', 'input', 'select', 'textarea', 'a'];
if (interactiveElements.includes(element.tag.toLowerCase())) {
priority += 10;
}
// Elements with roles get higher priority
if (element.attributes?.role) {
priority += 5;
}
// Elements with meaningful content get higher priority
if (element.content && element.content.trim().length > 0) {
priority += 3;
}
// Elements with ARIA labels get higher priority
if (element.attributes?.['aria-label']) {
priority += 5;
}
return priority;
}
// Generate multiple ID candidates and return the best one
generateCandidates(element, context, count = 3) {
const candidates = [];
// Strategy 1: Full semantic analysis
candidates.push(this.generate(element, context));
// Strategy 2: Simplified approach with just content + element type
if (element.content) {
const contentKeywords = this.extractContentKeywords(element.content);
const elementType = this.getElementType(element);
const simplified = [...contentKeywords, elementType].filter((item) => Boolean(item));
const simplifiedId = this.combineComponents(simplified, context.namingStrategy);
if (context.prefix) {
candidates.push(this.applyPrefix(simplifiedId, context.prefix, context.namingStrategy));
}
else {
candidates.push(simplifiedId);
}
}
// Strategy 3: Attribute-based approach
const attrKeywords = this.extractMeaningfulAttributes(element);
if (attrKeywords.length > 0) {
const elementType = this.getElementType(element);
const attrBased = [...attrKeywords.slice(0, 2), elementType].filter((item) => Boolean(item));
const attrBasedId = this.combineComponents(attrBased, context.namingStrategy);
if (context.prefix) {
candidates.push(this.applyPrefix(attrBasedId, context.prefix, context.namingStrategy));
}
else {
candidates.push(attrBasedId);
}
}
// Remove duplicates and ensure uniqueness
const uniqueCandidates = [...new Set(candidates)];
return uniqueCandidates
.map(id => this.resolveConflicts(id, context.existingIds))
.slice(0, count);
}
}
exports.IDGenerator = IDGenerator;
exports.idGenerator = new IDGenerator();
//# sourceMappingURL=id-generator.js.map