ctrlshiftleft
Version:
AI-powered toolkit for embedding QA and security testing into development workflows
597 lines (520 loc) • 20.3 kB
text/typescript
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;
}