UNPKG

ctrlshiftleft

Version:

AI-powered toolkit for embedding QA and security testing into development workflows

597 lines (520 loc) 20.3 kB
import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import * as t from '@babel/types'; import path from 'path'; import { TestTemplate } from '../types/testTypes'; // Add type definitions for Babel expression types interface NumericLiteral { type: 'NumericLiteral'; value: number; } interface StringLiteral { type: 'StringLiteral'; value: string; } interface FieldInfo { name: string; type: string; isRequired?: boolean; validation?: { min?: number; max?: number; pattern?: string; isEmail?: boolean; isCreditCard?: boolean; isPassword?: boolean; isAmount?: boolean; }; } /** * Generates a Test Data Factory for a component * @param sourcePath Path to the source component * @param sourceCode Source code of the component * @returns Test template with Test Data Factory content */ export function generateTestDataTemplate(sourcePath: string, sourceCode: string): TestTemplate { const template: TestTemplate = { content: '', format: 'typescript' }; // Extract component name from path const fileName = path.basename(sourcePath); const componentName = fileName.split('.')[0]; // Extract fields and their validation patterns from the component const fields = extractComponentFields(sourceCode); // Generate the Test Data Factory template.content = `/** * Test Data Factory for ${componentName} * Automatically generated by ctrl.shift.left */ export const ${componentName.toLowerCase()}Data = { // Valid test data valid: { ${generateValidTestData(fields)} }, // Invalid test data - general validation failures invalid: { ${generateInvalidTestData(fields)} }, // Security test data security: { xss: { ${generateXSSTestData(fields)} }, sqlInjection: { ${generateSQLInjectionTestData(fields)} } }, // Edge cases edgeCases: { // Boundary values ${generateEdgeCaseTestData(fields)} } }; ${generateHelperMethods(componentName, fields)} `; return template; } /** * Extract fields and their validation patterns from the component source code */ function extractComponentFields(sourceCode: string): FieldInfo[] { const fields: FieldInfo[] = []; try { // Parse the source code const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); // Track state variables const stateVars: Record<string, string> = {}; // Track validation patterns const validations: Record<string, any> = {}; // Traverse the AST traverse(ast, { // Find state variables VariableDeclarator(path) { if ( t.isIdentifier(path.node.id) && t.isCallExpression(path.node.init) && t.isIdentifier(path.node.init.callee) && path.node.init.callee.name === 'useState' ) { const varName = path.node.id.name; // Try to determine type from init value let varType = 'string'; if (path.node.init.arguments && path.node.init.arguments.length > 0) { const initArg = path.node.init.arguments[0]; if (t.isStringLiteral(initArg)) varType = 'string'; else if (t.isNumericLiteral(initArg)) varType = 'number'; else if (t.isBooleanLiteral(initArg)) varType = 'boolean'; else if (t.isObjectExpression(initArg)) varType = 'object'; else if (t.isArrayExpression(initArg)) varType = 'array'; } stateVars[varName] = varType; } }, // Look for validation patterns in the code CallExpression(path) { // Check for validation function calls like validateEmail, validatePassword, etc. if ( t.isIdentifier(path.node.callee) && path.node.callee.name.startsWith('validate') ) { const validationName = path.node.callee.name; const validationArg = path.node.arguments[0]; if (t.isIdentifier(validationArg)) { const fieldName = validationArg.name; if (!validations[fieldName]) { validations[fieldName] = {}; } if (validationName.includes('Email')) { validations[fieldName].isEmail = true; } else if (validationName.includes('Password')) { validations[fieldName].isPassword = true; } else if (validationName.includes('CreditCard') || validationName.includes('CardNumber')) { validations[fieldName].isCreditCard = true; } else if (validationName.includes('Amount') || validationName.includes('Money')) { validations[fieldName].isAmount = true; } } } // Check for pattern validation like string.match(PATTERN) if ( t.isMemberExpression(path.node.callee) && t.isIdentifier(path.node.callee.property) && path.node.callee.property.name === 'match' ) { const calleeObj = path.node.callee.object; const patternArg = path.node.arguments[0]; if (t.isIdentifier(calleeObj) && t.isRegExpLiteral(patternArg)) { const fieldName = calleeObj.name; const pattern = patternArg.pattern; if (!validations[fieldName]) { validations[fieldName] = {}; } validations[fieldName].pattern = pattern; // Analyze pattern to detect type if (pattern.includes('@')) { validations[fieldName].isEmail = true; } else if (pattern.includes('\\d') && pattern.includes('{')) { // Likely a credit card or numeric pattern if (pattern.includes('{16}') || pattern.includes('{13,19}')) { validations[fieldName].isCreditCard = true; } } } } }, // Find form elements and input fields from JSX JSXOpeningElement(path) { const elementName = path.node.name.type === 'JSXIdentifier' ? path.node.name.name : ''; const attributes = path.node.attributes; if (['input', 'select', 'textarea'].includes(elementName)) { let name = ''; let type = ''; let required = false; let pattern = ''; let min = undefined; let max = undefined; for (const attr of attributes) { if (attr.type === 'JSXAttribute') { // Get name attribute if (attr.name.name === 'name' && attr.value && attr.value.type === 'StringLiteral') { name = attr.value.value; } // Get id attribute as fallback if (!name && attr.name.name === 'id' && attr.value && attr.value.type === 'StringLiteral') { name = attr.value.value; } // Get data-testid as last resort if (!name && attr.name.name === 'data-testid' && attr.value && attr.value.type === 'StringLiteral') { name = attr.value.value.replace(/-input$/, ''); } // Get type attribute if (attr.name.name === 'type' && attr.value && attr.value.type === 'StringLiteral') { type = attr.value.value; } // Check for required if (attr.name.name === 'required' && !attr.value) { required = true; } // Get pattern if (attr.name.name === 'pattern' && attr.value && attr.value.type === 'StringLiteral') { pattern = attr.value.value; } // Get min/max if (attr.name.name === 'min' && attr.value) { if (t.isStringLiteral(attr.value)) { min = parseFloat(attr.value.value); } else if (t.isNumericLiteral(attr.value)) { min = (attr.value as NumericLiteral).value; } else if (t.isJSXExpressionContainer(attr.value)) { // Handle JSX expression containers safely if (t.isNumericLiteral(attr.value.expression)) { min = (attr.value.expression as NumericLiteral).value; } } } if (attr.name.name === 'max' && attr.value) { if (t.isStringLiteral(attr.value)) { max = parseFloat(attr.value.value); } else if (t.isNumericLiteral(attr.value)) { max = (attr.value as NumericLiteral).value; } else if (t.isJSXExpressionContainer(attr.value)) { // Handle JSX expression containers safely if (t.isNumericLiteral(attr.value.expression)) { max = (attr.value.expression as NumericLiteral).value; } } } } } // Determine field type based on input type let fieldType = 'string'; if (['number', 'range'].includes(type)) { fieldType = 'number'; } else if (type === 'checkbox') { fieldType = 'boolean'; } else if (['date', 'datetime-local', 'month', 'time', 'week'].includes(type)) { fieldType = 'date'; } // Infer validation type from name if not already determined if (!validations[name]) { validations[name] = {}; } if (pattern) { validations[name].pattern = pattern; } if (min !== undefined) { validations[name].min = min; } if (max !== undefined) { validations[name].max = max; } if (type === 'email' || name.includes('email')) { validations[name].isEmail = true; } else if (type === 'password' || name.includes('password')) { validations[name].isPassword = true; } else if (name.includes('card') && name.includes('number')) { validations[name].isCreditCard = true; } else if (name.includes('amount') || name.includes('price') || name.includes('cost')) { validations[name].isAmount = true; } // Add field if it has a name if (name) { fields.push({ name, type: fieldType, isRequired: required, validation: validations[name] }); } } } }); // Add state variables that don't have corresponding UI elements Object.entries(stateVars).forEach(([name, type]) => { // Check if this state var already has a UI element if (!fields.some(field => field.name === name)) { fields.push({ name, type, validation: validations[name] }); } }); } catch (error) { console.error('Error parsing component:', error); // Fallback with generic fields if parsing fails fields.push( { name: 'email', type: 'string', validation: { isEmail: true } }, { name: 'password', type: 'string', validation: { isPassword: true } }, { name: 'cardNumber', type: 'string', validation: { isCreditCard: true } }, { name: 'amount', type: 'string', validation: { isAmount: true } } ); } // If we couldn't extract any fields, provide some defaults if (fields.length === 0) { fields.push( { name: 'email', type: 'string', validation: { isEmail: true } }, { name: 'password', type: 'string', validation: { isPassword: true } }, { name: 'name', type: 'string' }, { name: 'description', type: 'string' } ); } return fields; } /** * Generate valid test data for each field */ function generateValidTestData(fields: FieldInfo[]): string { return fields.map(field => { let validValue = ''; if (field.type === 'string') { if (field.validation?.isEmail) { validValue = '"test@example.com"'; } else if (field.validation?.isPassword) { validValue = '"Password123!"'; } else if (field.validation?.isCreditCard) { validValue = '"4111111111111111"'; } else if (field.name.includes('cvv') || field.name.includes('cvc')) { validValue = '"123"'; } else if (field.name.includes('expiry') || field.name.includes('exp')) { validValue = '"12/25"'; } else if (field.validation?.isAmount) { validValue = '"100.00"'; } else if (field.name.includes('name')) { validValue = '"John Doe"'; } else if (field.name.includes('phone')) { validValue = '"555-123-4567"'; } else if (field.name.includes('address')) { validValue = '"123 Main St"'; } else if (field.name.includes('city')) { validValue = '"San Francisco"'; } else if (field.name.includes('state')) { validValue = '"CA"'; } else if (field.name.includes('zip') || field.name.includes('postal')) { validValue = '"94105"'; } else if (field.name.includes('country')) { validValue = '"USA"'; } else { validValue = '"test value"'; } } else if (field.type === 'number') { if (field.validation?.isAmount) { validValue = '100.00'; } else if (field.validation?.min !== undefined && field.validation?.max !== undefined) { // Pick a value between min and max const middle = (field.validation.min + field.validation.max) / 2; validValue = middle.toString(); } else if (field.validation?.min !== undefined) { validValue = (field.validation.min + 1).toString(); } else if (field.validation?.max !== undefined) { validValue = (field.validation.max - 1).toString(); } else { validValue = '42'; } } else if (field.type === 'boolean') { validValue = 'true'; } else if (field.type === 'date') { validValue = '"2025-01-01"'; } else { validValue = '"test value"'; } return ` ${field.name}: ${validValue},`; }).join('\n'); } /** * Generate invalid test data for each field */ function generateInvalidTestData(fields: FieldInfo[]): string { return fields.map(field => { let invalidValue = ''; if (field.type === 'string') { if (field.validation?.isEmail) { invalidValue = '"not-an-email"'; } else if (field.validation?.isPassword) { invalidValue = '"weak"'; } else if (field.validation?.isCreditCard) { invalidValue = '"1234"'; // Too short } else if (field.name.includes('cvv') || field.name.includes('cvc')) { invalidValue = '"abc"'; // Non-numeric } else if (field.name.includes('expiry') || field.name.includes('exp')) { invalidValue = '"invalid"'; } else if (field.validation?.isAmount) { invalidValue = '"-50"'; // Negative amount } else if (field.isRequired) { invalidValue = '""'; // Empty string for required fields } else { invalidValue = '""'; // Empty string as default invalid value } } else if (field.type === 'number') { if (field.validation?.isAmount) { invalidValue = '-50'; // Negative amount } else if (field.validation?.min !== undefined) { invalidValue = (field.validation.min - 1).toString(); // Below minimum } else if (field.validation?.max !== undefined) { invalidValue = (field.validation.max + 1).toString(); // Above maximum } else { invalidValue = 'NaN'; // Not a number } } else if (field.type === 'boolean') { invalidValue = 'null'; // null for boolean } else if (field.type === 'date') { invalidValue = '"invalid-date"'; // Invalid date string } else { invalidValue = 'null'; // null as default invalid value } return ` ${field.name}: ${invalidValue},`; }).join('\n'); } /** * Generate XSS test data for each field */ function generateXSSTestData(fields: FieldInfo[]): string { return fields .filter(field => field.type === 'string') .map(field => { return ` ${field.name}: ${field.name === 'email' ? '"test<script>alert(\\"XSS\\")</script>@example.com"' : '"test<script>alert(\\"XSS\\")</script>"'},`; }).join('\n'); } /** * Generate SQL injection test data for each field */ function generateSQLInjectionTestData(fields: FieldInfo[]): string { return fields .filter(field => field.type === 'string') .map(field => { return ` ${field.name}: "${field.name.includes('email') ? 'test\' OR 1=1 --@example.com' : 'test\' OR 1=1 --'}",`; }).join('\n'); } /** * Generate edge case test data for each field */ function generateEdgeCaseTestData(fields: FieldInfo[]): string { const results: string[] = []; // Add boundary values for amounts const amountFields = fields.filter(f => f.validation?.isAmount || f.name.includes('amount') || f.name.includes('price')); if (amountFields.length > 0) { results.push(' zeroAmount: "0.00",'); results.push(' largeAmount: "999999.99",'); } // Add boundary values for credit cards const cardFields = fields.filter(f => f.validation?.isCreditCard || (f.name.includes('card') && f.name.includes('number'))); if (cardFields.length > 0) { results.push(' minCardLength: "0".repeat(13), // Minimum valid length'); results.push(' maxCardLength: "0".repeat(19), // Maximum valid length'); } // Add special characters for string fields const nameFields = fields.filter(f => f.name.includes('name')); if (nameFields.length > 0) { results.push(' specialChars: {'); results.push(' nameOnCard: "O\'Connor-Smith Jr."'); results.push(' }'); } return results.join('\n'); } /** * Generate helper methods for the test data factory */ function generateHelperMethods(componentName: string, fields: FieldInfo[]): string { let methods = ''; // Generate card number helper if we have credit card fields const cardFields = fields.filter(f => f.validation?.isCreditCard || (f.name.includes('card') && f.name.includes('number'))); if (cardFields.length > 0) { methods += ` /** * Generate a random valid credit card number for testing * (For simulation only - these are not real card algorithms) */ export function generateValidCardNumber(type: 'visa' | 'mastercard' | 'amex' | 'discover' = 'visa'): string { const prefixes = { visa: '4', mastercard: '5', amex: '34', discover: '6011' }; const lengths = { visa: 16, mastercard: 16, amex: 15, discover: 16 }; const prefix = prefixes[type]; const length = lengths[type]; // Generate random digits for the rest of the card let cardNumber = prefix; const remainingDigits = length - prefix.length; for (let i = 0; i < remainingDigits; i++) { cardNumber += Math.floor(Math.random() * 10).toString(); } return cardNumber; }`; } // Generate expiry date helper if we have expiry date fields const expiryFields = fields.filter(f => f.name.includes('expiry') || f.name.includes('exp')); if (expiryFields.length > 0) { methods += ` /** * Generate a valid expiry date for testing (MM/YY format) * Always generates a date in the future */ export function generateValidExpiryDate(): string { const currentDate = new Date(); const month = Math.floor(Math.random() * 12) + 1; // 1-12 // Generate a year 1-10 years in the future const yearsToAdd = Math.floor(Math.random() * 10) + 1; const year = (currentDate.getFullYear() + yearsToAdd) % 100; // Get last 2 digits // Format with leading zeros if needed const formattedMonth = month.toString().padStart(2, '0'); const formattedYear = year.toString().padStart(2, '0'); return \`\${formattedMonth}/\${formattedYear}\`; }`; } return methods; }