UNPKG

ctrlshiftleft

Version:

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

406 lines (358 loc) 12.6 kB
import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import * as t from '@babel/types'; import fs from 'fs'; import path from 'path'; import { TestTemplate } from '../types/testTypes'; interface ComponentField { name: string; type: string; testId?: string; isForm?: boolean; isButton?: boolean; isInput?: boolean; isErrorMessage?: boolean; isSuccessMessage?: boolean; } /** * Generates a Page Object Model class for a component * @param sourcePath Path to the source component * @param sourceCode Source code of the component * @returns Test template with Page Object Model content */ export function generatePageObjectTemplate(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 types from the component const fields = extractComponentFields(sourceCode, componentName); // Generate the Page Object Model template.content = `/** * Page Object Model for ${componentName} * Automatically generated by ctrl.shift.left */ import { Page } from '@playwright/test'; export class ${componentName}Page { readonly page: Page; // Selectors for component elements readonly selectors = { ${generateSelectors(fields)} }; constructor(page: Page) { this.page = page; } /** * Navigate to the page containing the ${componentName} */ async goto(url = 'http://localhost:3000') { await this.page.goto(url); } ${generateInteractionMethods(fields, componentName)} } `; return template; } /** * Extract fields and their types from the component source code */ function extractComponentFields(sourceCode: string, componentName: string): ComponentField[] { const fields: ComponentField[] = []; try { // Parse the source code const ast = parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); // Track state variables and props const stateVars: Record<string, string> = {}; const props: string[] = []; // 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; const setterName = `set${varName.charAt(0).toUpperCase()}${varName.slice(1)}`; // 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; } }, // Find component props FunctionDeclaration(path) { if (path.node.id && path.node.id.name === componentName) { if (path.node.params.length > 0 && t.isIdentifier(path.node.params[0])) { // This is likely the props parameter const propsParam = path.node.params[0].name; // Find object destructuring of props path.traverse({ VariableDeclarator(innerPath) { if ( t.isObjectPattern(innerPath.node.id) && t.isIdentifier(innerPath.node.init) && innerPath.node.init.name === propsParam ) { innerPath.node.id.properties.forEach(prop => { if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { props.push(prop.key.name); } }); } } }); } } }, // 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; // Extract data-testid attribute if present let testId = ''; let name = ''; let type = ''; let isForm = false; let isButton = false; let isInput = false; let isErrorMessage = false; let isSuccessMessage = false; for (const attr of attributes) { if (attr.type === 'JSXAttribute') { // Get data-testid if (attr.name.name === 'data-testid' && attr.value && attr.value.type === 'StringLiteral') { testId = attr.value.value; name = testId; } // 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 type attribute if (attr.name.name === 'type' && attr.value && attr.value.type === 'StringLiteral') { type = attr.value.value; } } } // Identify element type if (elementName === 'form') { isForm = true; name = name || 'form'; type = 'form'; } else if (elementName === 'button' || type === 'submit') { isButton = true; name = name || (type === 'submit' ? 'submit-button' : 'button'); type = 'button'; } else if (elementName === 'input' || elementName === 'select' || elementName === 'textarea') { isInput = true; name = name || 'input'; type = type || elementName; } else if ( testId?.includes('error') || elementName.toLowerCase().includes('error') ) { isErrorMessage = true; name = name || 'error-message'; type = 'error'; } else if ( testId?.includes('success') || elementName.toLowerCase().includes('success') ) { isSuccessMessage = true; name = name || 'success-message'; type = 'success'; } // Add field if it has a name if (name && (isForm || isButton || isInput || isErrorMessage || isSuccessMessage)) { fields.push({ name, type: type || 'element', testId, isForm, isButton, isInput, isErrorMessage, isSuccessMessage }); } } }); // 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, testId: undefined }); } }); } catch (error) { console.error('Error parsing component:', error); // Fallback with generic fields if parsing fails fields.push( { name: 'form', type: 'form', isForm: true }, { name: 'input', type: 'input', isInput: true }, { name: 'submit-button', type: 'button', isButton: true }, { name: 'error-message', type: 'error', isErrorMessage: true }, { name: 'success-message', type: 'success', isSuccessMessage: true } ); } return fields; } /** * Generate selectors for the Page Object Model */ function generateSelectors(fields: ComponentField[]): string { return fields.map(field => { const selectorName = field.name .replace(/[^a-zA-Z0-9]/g, '_') .replace(/_+/g, '_') .toLowerCase(); const testIdSelector = field.testId ? `'[data-testid="${field.testId}"]'` : `'[data-testid="${field.name.replace(/\s+/g, '-').toLowerCase()}${field.isInput ? '-input' : ''}"]'`; return ` ${selectorName}: ${testIdSelector},`; }).join('\n'); } /** * Generate interaction methods based on component fields */ function generateInteractionMethods(fields: ComponentField[], componentName: string): string { let methods = ''; // Form submission method if (fields.some(f => f.isForm)) { methods += ` /** * Submit the form */ async submitForm() { await this.page.click(this.selectors.submit_button); } `; } // Fill form method if there are input fields const inputFields = fields.filter(f => f.isInput); if (inputFields.length > 0) { methods += ` /** * Fill the form with the provided data */ async fillForm(data: { ${inputFields.map(field => ` ${field.name}?: string;`).join('\n')} }) { ${inputFields.map(field => ` if (data.${field.name} !== undefined) { await this.page.fill(this.selectors.${field.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}, data.${field.name}); }`).join('\n')} } /** * Fill the form with valid test data */ async fillWithValidData() { await this.fillForm({ ${inputFields.map(field => { let validValue = '"test"'; if (field.name.includes('email')) validValue = '"test@example.com"'; if (field.name.includes('password')) validValue = '"Password123!"'; if (field.name.includes('card') && field.name.includes('number')) validValue = '"4111111111111111"'; if (field.name.includes('cvv') || field.name.includes('cvc')) validValue = '"123"'; if (field.name.includes('expiry') || field.name.includes('exp')) validValue = '"12/25"'; if (field.name.includes('amount') || field.name.includes('price')) validValue = '"100.00"'; return ` ${field.name}: ${validValue}`; }).join(',\n')} }); } `; } // Error message getter if there are error messages if (fields.some(f => f.isErrorMessage)) { methods += ` /** * Get error message text if present */ async getErrorMessage() { const errorSelector = this.selectors.error_message; await this.page.waitForSelector(errorSelector, { state: 'visible', timeout: 5000 }); return this.page.textContent(errorSelector); } `; } // Success message getter if there are success messages if (fields.some(f => f.isSuccessMessage)) { methods += ` /** * Get success message text if present */ async getSuccessMessage() { const successSelector = this.selectors.success_message; await this.page.waitForSelector(successSelector, { state: 'visible', timeout: 5000 }); return this.page.textContent(successSelector); } `; } // Generate validation methods methods += ` /** * Test if XSS payloads are sanitized */ async testXSSSanitization() { // Fill form with XSS payloads await this.fillForm(${inputFields.map(field => `{ ${field.name}: "Test<script>alert('XSS')</script>" }`).join(', ')}); await this.submitForm(); // Check if the page contains unsanitized script tags const content = await this.page.content(); return !content.includes('<script>alert'); } /** * Test CSRF token validation */ async testCSRFProtection(invalidToken = 'invalid-token') { await this.page.evaluate(({ token }) => { // Modify CSRF token in meta tag if present const csrfMetaTag = document.querySelector('meta[name="csrf-token"]'); if (csrfMetaTag) { csrfMetaTag.setAttribute('content', token); } // Modify CSRF token in sessionStorage if used sessionStorage.setItem('csrf-token', token); // Modify CSRF token in localStorage if used localStorage.setItem('csrf-token', token); }, { token: invalidToken }); await this.submitForm(); // Check for security error try { await this.page.waitForSelector('[data-testid*="error"]', { state: 'visible', timeout: 5000 }); return true; } catch (e) { return false; } } `; return methods; }