const { spawn } = require('child_process'); const { PrismaClient } = require('@prisma/client'); const { PrismaPg } = require('@prisma/adapter-pg'); const { Pool } = require('pg'); const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); require('dotenv').config(); const PORT = 3010; const BASE_URL = `http://localhost:${PORT}`; const SCREENSHOT_DIR = path.join(__dirname, 'screenshots'); let nextProcess; let prisma; let pool; let browser; // Ensure screenshot dir exists if (!fs.existsSync(SCREENSHOT_DIR)) { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); } function getPrisma() { if (!prisma) { const dbUrl = new URL(process.env.DATABASE_URL); pool = new Pool({ host: dbUrl.hostname, port: dbUrl.port ? parseInt(dbUrl.port) : 5432, user: decodeURIComponent(dbUrl.username), password: decodeURIComponent(dbUrl.password), database: dbUrl.pathname.substring(1).split('?')[0], ssl: false }); const adapter = new PrismaPg(pool); prisma = new PrismaClient({ adapter }); } return prisma; } async function runAsAdmin(queryFn) { const db = getPrisma(); return db.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); return queryFn(tx); }); } async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } let passed = 0; let failed = 0; function assert(condition, message) { if (condition) { console.log(` ✓ PASS: ${message}`); passed++; } else { console.error(` ✗ FAIL: ${message}`); failed++; } } async function runTests() { console.log("=== STARTING PHASE 3 E2E PUPPETEER TEST SUITE ==="); // 1. Clean database records first console.log("Resetting test environment data..."); await runAsAdmin(async (tx) => { // Delete calculation rules associated with E2E test plans await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-E2E-PUPP' } } }); // Delete E2E test goals await tx.goal.deleteMany({ where: { period: '2026-06', amount: 75000.00 } }); // Delete E2E test plans await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-E2E-PUPP' } }); }); // 2. Start Next.js production server on port 3010 console.log(`Starting Next.js production server on port ${PORT}...`); nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], { env: { ...process.env, PORT: String(PORT) } }); nextProcess.stdout.on('data', (data) => { console.log(`[Next.js] ${data.toString().trim()}`); }); nextProcess.stderr.on('data', (data) => { console.error(`[Next.js ERR] ${data.toString().trim()}`); }); // Wait for server to start up let serverReady = false; for (let i = 0; i < 15; i++) { await sleep(2000); try { const res = await fetch(`${BASE_URL}/api/auth/me`); serverReady = true; break; } catch (e) { // Server not ready yet } } if (!serverReady) { console.error("Error: Next.js dev server failed to start within timeout."); await cleanup(); process.exit(1); } console.log("Next.js dev server is ready!"); // 3. Launch Puppeteer browser console.log("Launching Puppeteer browser..."); browser = await puppeteer.launch({ headless: 'shell', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); // Helper to set value of React-controlled inputs natively const setReactInput = async (selector, value) => { await page.$eval(selector, (el, val) => { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value" ).set; nativeSetter.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, value); }; // --- STEP 1: LOGIN FLOW --- console.log("\n[Step 1] Navigating to login page..."); await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_login_page.png') }); console.log("Entering admin credentials..."); await page.type('#username', 'admin'); await page.type('#password', 'password123'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_login_typed.png') }); console.log("Clicking submit..."); await page.click('button[type="submit"]'); await page.waitForSelector('#btn-create-plan'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_dashboard_loaded.png') }); assert(page.url().endsWith('/plans'), "Redirected to /plans after successful login"); // --- STEP 2: CREATE PLAN FLOW --- console.log("\n[Step 2] Creating a new plan..."); await page.click('#btn-create-plan'); await page.waitForSelector('div[role="dialog"]'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') }); // Trigger save without filling fields to test HTML5 validations console.log("Testing empty form submission block..."); await page.click('#btn-save-plan'); await sleep(1000); const isModalOpen = await page.evaluate(() => { return document.querySelector('div[role="dialog"]') !== null; }); assert(isModalOpen, "HTML5 constraint validation: Modal remains open when attempting to submit empty fields"); // Fill in form details await page.type('#plan-name', 'Plan Ventas E2E Puppeteer'); await page.type('#plan-code', 'PLAN-E2E-PUPP'); await setReactInput('#plan-validity-start', '2026-06-01'); await page.select('#plan-type', 'SCALE'); await page.type('#plan-meta-amount', '100000'); await page.type('#plan-max-cap', '20000'); await page.select('#plan-status', 'DRAFT'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_create_form_filled.png') }); // Save the plan await page.click('#btn-save-plan'); await page.waitForSelector('div[role="dialog"]', { hidden: true }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_plan_created_grid.png') }); // Verify plan is in the database const createdPlan = await runAsAdmin(tx => tx.compensationPlan.findFirst({ where: { code: 'PLAN-E2E-PUPP' } })); assert(createdPlan !== null, "Plan 'PLAN-E2E-PUPP' successfully created in database"); assert(createdPlan.status === 'DRAFT', "Plan initially set to status 'DRAFT'"); // --- STEP 3: RULES CONFIG FLOW --- // Helper to clear input and type new text const clearAndType = async (selector, text) => { await page.click(selector, { clickCount: 3 }); await page.keyboard.press('Backspace'); await page.type(selector, text); }; console.log("\n[Step 3] Configuring rules for the new plan..."); const rulesBtnSelector = `#btn-rules-${createdPlan.id}`; await page.click(rulesBtnSelector); await page.waitForSelector('#rule-min-0'); await sleep(1500); // Allow React reconciliation to settle await page.screenshot({ path: path.join(SCREENSHOT_DIR, '07_rules_config_page.png') }); assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page"); // Test invalid rule boundary constraint (min >= max) console.log("Testing rule validation constraints (min >= max)..."); await setReactInput('#rule-min-0', '0.95'); await setReactInput('#rule-max-0', '0.90'); await page.click('#btn-save-rules'); await page.waitForSelector('#rules-error-msg'); const rulesError = await page.$eval('#rules-error-msg', el => el.textContent); assert(rulesError.includes("El logro mínimo") && rulesError.includes("debe ser menor"), "Validation error shows when rule minimum exceeds maximum"); // Modify first row back to valid values await setReactInput('#rule-min-0', '0.0'); await setReactInput('#rule-max-0', '0.9'); await setReactInput('#rule-rate-0', '0.0'); await setReactInput('#rule-payout-0', '0.0'); // Add a second row await page.click('#btn-add-rule'); await page.waitForSelector('#rule-min-1'); await sleep(500); // Allow React state to settle // Fill second row await setReactInput('#rule-min-1', '0.9'); await setReactInput('#rule-max-1', '1.0'); await setReactInput('#rule-rate-1', '0.025'); await setReactInput('#rule-payout-1', '150.0'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') }); // Save rules await page.click('#btn-save-rules'); await page.waitForSelector('#rules-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '09_rules_saved_successfully.png') }); // Verify rules exist in database const rulesInDb = await runAsAdmin(tx => tx.calculationRule.findMany({ where: { planId: createdPlan.id } })); assert(rulesInDb.length === 2, "2 calculation rules successfully inserted into the database"); // --- STEP 4: VERSIONING ACTIVATION FLOW --- console.log("\n[Step 4] Activating and versioning check..."); // Go back to plans await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '10_back_to_plans.png') }); // Toggle status to ACTIVE const activateSelector = `#btn-toggle-status-${createdPlan.id}`; await page.click(activateSelector); await sleep(1000); // Wait for toggle update refresh await page.screenshot({ path: path.join(SCREENSHOT_DIR, '11_plan_activated.png') }); // Verify status is ACTIVE in db let activePlan = await runAsAdmin(tx => tx.compensationPlan.findUnique({ where: { id: createdPlan.id } })); assert(activePlan.status === 'ACTIVE', "Plan status in database updated to 'ACTIVE'"); // Toggle status AGAIN while active to trigger versioning duplicate logic await page.click(activateSelector); await sleep(1500); // Wait for clone and page reload await page.screenshot({ path: path.join(SCREENSHOT_DIR, '12_plan_versioned.png') }); // Verify database contains cloned version const allVersions = await runAsAdmin(tx => tx.compensationPlan.findMany({ where: { code: 'PLAN-E2E-PUPP' }, orderBy: { version: 'asc' } })); assert(allVersions.length === 2, "Cloned version successfully created (2 versions exist)"); assert(allVersions[0].status === 'INACTIVE' && allVersions[0].validityEnd !== null, "Version 1 is now INACTIVE with validity_end set"); assert(allVersions[1].status === 'ACTIVE' && allVersions[1].version === 2, "Version 2 is now ACTIVE with version = 2"); // Verify rules cloned for version 2 const clonedRules = await runAsAdmin(tx => tx.calculationRule.findMany({ where: { planId: allVersions[1].id } })); assert(clonedRules.length === 2, "Calculation rules cloned to Version 2 successfully"); // --- STEP 5: GOAL ASSIGNMENT FLOW --- console.log("\n[Step 5] Assigning commercial goal..."); await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '13_goals_page.png') }); // Select colaborador_mde const colaboradorMde = await runAsAdmin(tx => tx.user.findUnique({ where: { username: 'colaborador_mde' } })); await page.select('#goal-target-type', 'INDIVIDUAL'); await page.select('#goal-target-id', colaboradorMde.id.toString()); await setReactInput('#goal-period', '2026-06'); // Test invalid goal amount validation console.log("Testing invalid goal amount validation..."); await setReactInput('#goal-amount', '-100'); await page.click('#btn-save-goal'); await page.waitForSelector('#goal-error-msg'); const goalErrorMsg = await page.$eval('#goal-error-msg', el => el.textContent); assert(goalErrorMsg.includes("El monto debe ser un número positivo"), "Validation error shows when entering negative goal amount"); // Enter valid amount await setReactInput('#goal-amount', '75000'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') }); await page.click('#btn-save-goal'); // Wait for success alert await page.waitForSelector('#goal-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '15_goal_saved.png') }); // Check db for goal const savedGoal = await runAsAdmin(tx => tx.goal.findFirst({ where: { targetType: 'INDIVIDUAL', targetId: colaboradorMde.id, period: '2026-06' } })); assert(savedGoal !== null, "Goal record successfully created in the database"); assert(parseFloat(savedGoal.amount) === 75000.00, "Goal amount is correctly set to 75,000.00"); console.log("\n[Step 6] Verifying RLS boundaries on Goals page..."); // Log out admin await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' }); await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button')); const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión')); if (logoutBtn) logoutBtn.click(); }); await sleep(1500); // Log in as Colaborador await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.type('#username', 'colaborador_mde'); await page.type('#password', 'password123'); await page.click('button[type="submit"]'); await page.waitForSelector('header'); const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan')); assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard"); // Navigate directly to /goals and verify list shows only their goal await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '16_goals_colaborador_rls.png') }); // Evaluate list rows shown to the collaborator const rowCount = await page.evaluate(() => { // Count all table rows in tbody return document.querySelectorAll('tbody tr').length; }); assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard"); // Verify API security boundary: Colaborador cannot create plans or assign goals console.log("Verifying collaborator is blocked from executing ADMIN/DIRECTOR APIs..."); const planBlockRes = await page.evaluate(async () => { const res = await fetch('/api/plans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Plan Hack', code: 'PLAN-HACK', validityStart: '2026-06-01', type: 'PERCENTAGE' }) }); return { status: res.status }; }); assert(planBlockRes.status === 403, "API security check: Collaborator blocked from creating a plan (POST /api/plans returns 403)"); const goalBlockRes = await page.evaluate(async () => { const res = await fetch('/api/goals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 'INDIVIDUAL', targetId: 7, period: '2026-06', amount: 85000.00 }) }); return { status: res.status }; }); assert(goalBlockRes.status === 403, "API security check: Collaborator blocked from assigning goals (POST /api/goals returns 403)"); await cleanup(); console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`); if (failed > 0) { process.exit(1); } else { process.exit(0); } } async function cleanup() { console.log("\nCleaning up E2E test browser and server processes..."); try { if (browser) { await browser.close(); } } catch (e) {} try { if (prisma) { await runAsAdmin(async (tx) => { // Delete calculation rules associated with E2E test plans await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-E2E-PUPP' } } }); // Delete E2E test goals await tx.goal.deleteMany({ where: { period: '2026-06', amount: 75000.00 } }); // Delete E2E test plans await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-E2E-PUPP' } }); }); await prisma.$disconnect(); } if (pool) { await pool.end(); } } catch (e) { console.error("Error during DB cleanup:", e); } if (nextProcess) { nextProcess.kill(); } } process.on('SIGINT', () => { cleanup(); process.exit(1); }); runTests().catch(async err => { console.error("Fatal E2E test runner error:", err); if (browser) { try { const pages = await browser.pages(); if (pages.length > 0) { await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') }); console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png')); } } catch (screenshotErr) { console.error("Failed to capture error screenshot:", screenshotErr); } } await cleanup(); process.exit(1); });