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