Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
821 lines
31 KiB
TypeScript
821 lines
31 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
// All 10 pages to test
|
|
const ALL_PAGES = [
|
|
{ name: 'Home', url: '/', titleFragment: 'Transforming Ideas' },
|
|
{ name: 'About', url: '/about', titleFragment: 'About Us' },
|
|
{ name: 'Services', url: '/services', titleFragment: 'Our Services' },
|
|
{ name: 'Portfolio', url: '/portfolio', titleFragment: 'Our Portfolio' },
|
|
{ name: 'Blog', url: '/blog', titleFragment: 'Blog' },
|
|
{ name: 'Contact', url: '/contact', titleFragment: 'Contact Us' },
|
|
{ name: 'Privacy', url: '/privacy', titleFragment: 'Privacy Policy' },
|
|
{ name: 'Terms', url: '/terms', titleFragment: 'Terms of Service' },
|
|
{ name: 'Sitemap', url: '/sitemap', titleFragment: 'Sitemap' },
|
|
{ name: '404', url: '/nonexistent-page-xyz', titleFragment: '' },
|
|
];
|
|
|
|
// Viewport sizes for responsive testing
|
|
const VIEWPORTS = [
|
|
{ name: 'Mobile', width: 375, height: 667 },
|
|
{ name: 'Tablet', width: 768, height: 1024 },
|
|
{ name: 'Desktop', width: 1280, height: 800 },
|
|
{ name: 'Wide', width: 1920, height: 1080 },
|
|
];
|
|
|
|
// ============================================================
|
|
// 1. Cross-Browser Page Load Testing (runs on all configured browsers)
|
|
// ============================================================
|
|
test.describe('Cross-Browser: All Pages Load', () => {
|
|
for (const p of ALL_PAGES) {
|
|
test(`${p.name} page loads successfully`, async ({ page }) => {
|
|
const consoleErrors: string[] = [];
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
});
|
|
page.on('pageerror', (err) => consoleErrors.push(err.message));
|
|
|
|
const response = await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (p.url === '/nonexistent-page-xyz') {
|
|
expect([404, 200]).toContain(response?.status()); // Some frameworks serve 200 with error page
|
|
} else {
|
|
expect(response?.status()).toBe(200);
|
|
}
|
|
|
|
// Verify no critical JS errors (filter out known non-critical browser noise)
|
|
const critical = consoleErrors.filter(
|
|
(e) =>
|
|
!e.includes('favicon') &&
|
|
!e.includes('livereload') &&
|
|
!e.includes('DevTools') &&
|
|
!e.includes('manifest') &&
|
|
!e.includes('net::ERR_ABORTED') // image lazy-load aborts on fast navigation
|
|
);
|
|
if (critical.length > 0) {
|
|
console.warn(`[${p.name}] Console errors:`, critical);
|
|
}
|
|
expect(critical, `Unexpected console errors on ${p.name}`).toHaveLength(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 2. Cross-Browser: Critical Layout Elements Present
|
|
// ============================================================
|
|
test.describe('Cross-Browser: Layout Elements', () => {
|
|
const CONTENT_PAGES = ALL_PAGES.filter((p) => p.url !== '/nonexistent-page-xyz');
|
|
|
|
for (const p of CONTENT_PAGES) {
|
|
test(`${p.name} has header, main content, and footer`, async ({ page }) => {
|
|
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
|
|
// Fixed header is always present
|
|
await expect(page.locator('#main-header')).toBeAttached();
|
|
|
|
// Footer is always present
|
|
await expect(page.locator('footer')).toBeAttached();
|
|
|
|
// Page has at least one <section> or <main> with content
|
|
const mainContent = page.locator('main, section').first();
|
|
await expect(mainContent).toBeAttached();
|
|
|
|
// No invisible page (must have visible body text)
|
|
const bodyText = await page.locator('body').innerText();
|
|
expect(bodyText.trim().length).toBeGreaterThan(50);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 3. Responsive Design Testing
|
|
// ============================================================
|
|
test.describe('Responsive Design', () => {
|
|
test.describe('Home page responsive layout', () => {
|
|
for (const vp of VIEWPORTS) {
|
|
test(`renders correctly at ${vp.name} (${vp.width}x${vp.height})`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Header is always visible
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
|
|
// Hero section (first section) is visible
|
|
await expect(page.locator('section').first()).toBeVisible();
|
|
|
|
// Footer visible
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
|
|
// On mobile/tablet: desktop nav is hidden, hamburger is visible
|
|
if (vp.width < 1024) {
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeVisible();
|
|
await expect(page.locator('ul[role="menubar"]')).toBeHidden();
|
|
} else {
|
|
// On desktop: hamburger is hidden, nav is visible
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeHidden();
|
|
await expect(page.locator('ul[role="menubar"]')).toBeVisible();
|
|
}
|
|
|
|
// Screenshot for visual reference
|
|
await page.screenshot({
|
|
path: `tests/screenshots/${vp.name.toLowerCase()}-home.png`,
|
|
fullPage: false,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Services page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
// Mobile + Desktop
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
const content = await page.locator('body').innerText();
|
|
expect(content).toContain('Services');
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Portfolio page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[1], VIEWPORTS[2]]) {
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Filter tabs and project grid must be visible
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible();
|
|
await expect(page.locator('#projects-grid')).toBeVisible();
|
|
|
|
// At least one project card must be visible
|
|
const cards = page.locator('.project-card');
|
|
await expect(cards.first()).toBeVisible();
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Contact page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
test(`form is usable at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('#contact-form')).toBeVisible();
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
|
|
// Submit button is visible and clickable
|
|
const submitBtn = page.locator('#contact-form button[type="submit"]');
|
|
await expect(submitBtn).toBeVisible();
|
|
await expect(submitBtn).toBeEnabled();
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Blog page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 4. Navigation Testing
|
|
// ============================================================
|
|
test.describe('Navigation', () => {
|
|
test('Desktop nav links navigate to correct pages', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const navItems = [
|
|
{ label: 'About', path: '/about' },
|
|
{ label: 'Services', path: '/services' },
|
|
{ label: 'Portfolio', path: '/portfolio' },
|
|
{ label: 'Blog', path: '/blog' },
|
|
{ label: 'Contact', path: '/contact' },
|
|
];
|
|
|
|
for (const item of navItems) {
|
|
// Navigate back to home first
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Click the nav link in the desktop menubar
|
|
const navLink = page.locator(`ul[role="menubar"] a[href="${item.path}"]`);
|
|
await expect(navLink).toBeVisible();
|
|
await navLink.click();
|
|
|
|
await page.waitForURL(`**${item.path}**`, { timeout: 5000 });
|
|
expect(page.url()).toContain(item.path);
|
|
}
|
|
});
|
|
|
|
test('Logo navigates to home page', async ({ page }) => {
|
|
await page.goto('/about', { waitUntil: 'domcontentloaded' });
|
|
|
|
const logo = page.locator('#main-header a[aria-label*="WorkRoot"]').first();
|
|
await expect(logo).toBeVisible();
|
|
await logo.click();
|
|
await page.waitForURL('**/', { timeout: 5000 });
|
|
expect(page.url()).toMatch(/\/$|\/$/);
|
|
});
|
|
|
|
test('"Get Started" CTA in header navigates to contact', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const ctaBtn = page.locator('#main-header a[href="/contact"]').first();
|
|
await expect(ctaBtn).toBeVisible();
|
|
await ctaBtn.click();
|
|
await page.waitForURL('**/contact', { timeout: 5000 });
|
|
expect(page.url()).toContain('/contact');
|
|
});
|
|
|
|
test('Footer privacy link navigates correctly', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
const privacyLink = page.locator('footer a[href="/privacy"]').first();
|
|
await expect(privacyLink).toBeVisible();
|
|
await privacyLink.click();
|
|
await page.waitForURL('**/privacy', { timeout: 5000 });
|
|
expect(page.url()).toContain('/privacy');
|
|
});
|
|
|
|
test('Footer terms link navigates correctly', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
const termsLink = page.locator('footer a[href="/terms"]').first();
|
|
await expect(termsLink).toBeVisible();
|
|
await termsLink.click();
|
|
await page.waitForURL('**/terms', { timeout: 5000 });
|
|
expect(page.url()).toContain('/terms');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 5. Mobile Navigation (hamburger menu)
|
|
// ============================================================
|
|
test.describe('Mobile Navigation Menu', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
test('Hamburger button is visible on mobile', async ({ page }) => {
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeVisible();
|
|
});
|
|
|
|
test('Desktop nav is hidden on mobile', async ({ page }) => {
|
|
await expect(page.locator('ul[role="menubar"]')).toBeHidden();
|
|
});
|
|
|
|
test('Mobile menu panel opens when hamburger is clicked', async ({ page }) => {
|
|
const toggle = page.locator('#mobile-menu-toggle');
|
|
const menu = page.locator('#mobile-menu');
|
|
|
|
// Menu starts closed (translated off-screen)
|
|
await expect(menu).toHaveClass(/translate-x-full/);
|
|
|
|
await toggle.click();
|
|
await page.waitForTimeout(400); // CSS transition
|
|
|
|
// Menu slides in
|
|
await expect(menu).not.toHaveClass(/translate-x-full/);
|
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
test('Mobile menu closes when close button is clicked', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const closeBtn = page.locator('#mobile-menu-close');
|
|
await expect(closeBtn).toBeVisible();
|
|
await closeBtn.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const menu = page.locator('#mobile-menu');
|
|
await expect(menu).toHaveClass(/translate-x-full/);
|
|
await expect(page.locator('#mobile-menu-toggle')).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
test('Mobile menu closes when overlay is clicked', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overlay = page.locator('#mobile-menu-overlay');
|
|
await overlay.click({ force: true });
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
});
|
|
|
|
test('Mobile menu closes on Escape key', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
await expect(page.locator('#mobile-menu-toggle')).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
test('Mobile menu links navigate correctly', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Click "Services" in mobile nav
|
|
const servicesLink = page.locator('#mobile-menu a[href="/services"]');
|
|
await expect(servicesLink).toBeVisible();
|
|
await servicesLink.click();
|
|
|
|
await page.waitForURL('**/services', { timeout: 5000 });
|
|
expect(page.url()).toContain('/services');
|
|
});
|
|
|
|
test('Body scroll is locked while menu is open', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overflow = await page.evaluate(() => document.body.style.overflow);
|
|
expect(overflow).toBe('hidden');
|
|
|
|
// Scroll re-enabled on close
|
|
await page.locator('#mobile-menu-close').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overflowAfter = await page.evaluate(() => document.body.style.overflow);
|
|
expect(overflowAfter).toBe('');
|
|
});
|
|
|
|
test('Menu closes automatically on resize to desktop', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Resize to desktop
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 6. Contact Form Testing
|
|
// ============================================================
|
|
test.describe('Contact Form', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
test('Form elements are all present', async ({ page }) => {
|
|
await expect(page.locator('#contact-form')).toBeVisible();
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#phone')).toBeVisible();
|
|
await expect(page.locator('#subject')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
|
|
// Honeypot field is present but visually hidden
|
|
await expect(page.locator('#website')).toBeAttached();
|
|
await expect(page.locator('#website')).not.toBeVisible();
|
|
});
|
|
|
|
test('Subject dropdown has all expected options', async ({ page }) => {
|
|
const options = await page.locator('#subject option').allTextContents();
|
|
expect(options).toContain('Web Development');
|
|
expect(options).toContain('Mobile Development');
|
|
expect(options).toContain('Cloud Services');
|
|
expect(options).toContain('AI & Machine Learning');
|
|
expect(options).toContain('IT Consulting');
|
|
expect(options).toContain('Technical Support');
|
|
expect(options).toContain('Other');
|
|
});
|
|
|
|
test('Error messages are hidden initially', async ({ page }) => {
|
|
await expect(page.locator('[data-error="name"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="email"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="subject"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="message"]')).toBeHidden();
|
|
});
|
|
|
|
test('Submit button is visible and enabled', async ({ page }) => {
|
|
const btn = page.locator('#contact-form button[type="submit"]');
|
|
await expect(btn).toBeVisible();
|
|
await expect(btn).toBeEnabled();
|
|
await expect(btn).toContainText('Send Message');
|
|
});
|
|
|
|
test('Name field accepts valid input', async ({ page }) => {
|
|
const nameInput = page.locator('#name');
|
|
await nameInput.fill('John Doe');
|
|
expect(await nameInput.inputValue()).toBe('John Doe');
|
|
});
|
|
|
|
test('Email field accepts valid email format', async ({ page }) => {
|
|
const emailInput = page.locator('#email');
|
|
await emailInput.fill('john@example.com');
|
|
expect(await emailInput.inputValue()).toBe('john@example.com');
|
|
});
|
|
|
|
test('Phone field is optional and accepts phone number', async ({ page }) => {
|
|
const phoneInput = page.locator('#phone');
|
|
await phoneInput.fill('+1 (555) 123-4567');
|
|
expect(await phoneInput.inputValue()).toBe('+1 (555) 123-4567');
|
|
});
|
|
|
|
test('Message textarea accepts multi-line text', async ({ page }) => {
|
|
const messageInput = page.locator('#message');
|
|
const testMessage = 'This is a test message.\nWith multiple lines.\nFor our project.';
|
|
await messageInput.fill(testMessage);
|
|
expect(await messageInput.inputValue()).toBe(testMessage);
|
|
});
|
|
|
|
test('Full form can be filled out completely', async ({ page }) => {
|
|
await page.fill('#name', 'Test User');
|
|
await page.fill('#email', 'test@example.com');
|
|
await page.fill('#phone', '+1234567890');
|
|
await page.selectOption('#subject', 'web-development');
|
|
await page.fill('#message', 'This is a complete test message for automated testing purposes.');
|
|
|
|
// All fields should have their values
|
|
expect(await page.locator('#name').inputValue()).toBe('Test User');
|
|
expect(await page.locator('#email').inputValue()).toBe('test@example.com');
|
|
expect(await page.locator('#subject').inputValue()).toBe('web-development');
|
|
expect((await page.locator('#message').inputValue()).length).toBeGreaterThan(10);
|
|
});
|
|
|
|
test('Honeypot field is not visible to users', async ({ page }) => {
|
|
// Real users can't see or interact with the honeypot field
|
|
const honeypot = page.locator('#website');
|
|
await expect(honeypot).toBeAttached();
|
|
|
|
// It should not be focusable via tab (tabindex="-1")
|
|
const tabIndex = await honeypot.getAttribute('tabindex');
|
|
expect(tabIndex).toBe('-1');
|
|
});
|
|
|
|
test('Form is accessible on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
|
|
// All required fields are visible and tappable on mobile
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#subject')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
await expect(page.locator('#contact-form button[type="submit"]')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 7. Portfolio Filter Functionality
|
|
// ============================================================
|
|
test.describe('Portfolio Filters', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
// Wait for project cards to be rendered
|
|
await expect(page.locator('.project-card').first()).toBeVisible();
|
|
});
|
|
|
|
test('All filter buttons are present', async ({ page }) => {
|
|
await expect(page.locator('button[data-filter="all"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="web"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="mobile"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="ai"]')).toBeVisible();
|
|
});
|
|
|
|
test('"All Projects" filter is active by default', async ({ page }) => {
|
|
const allBtn = page.locator('button[data-filter="all"]');
|
|
await expect(allBtn).toHaveClass(/active/);
|
|
await expect(allBtn).toHaveAttribute('aria-selected', 'true');
|
|
|
|
// All 8 project cards should be visible
|
|
const cards = page.locator('.project-card');
|
|
await expect(cards).toHaveCount(8);
|
|
});
|
|
|
|
test('"Web Development" filter shows only web projects', async ({ page }) => {
|
|
const webBtn = page.locator('button[data-filter="web"]');
|
|
await webBtn.click();
|
|
await page.waitForTimeout(500); // CSS animation
|
|
|
|
await expect(webBtn).toHaveAttribute('aria-selected', 'true');
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
// All visible cards should be web category
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'web')).toBe(true);
|
|
});
|
|
|
|
test('"Mobile Apps" filter shows only mobile projects', async ({ page }) => {
|
|
await page.locator('button[data-filter="mobile"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'mobile')).toBe(true);
|
|
});
|
|
|
|
test('"AI & ML" filter shows only AI projects', async ({ page }) => {
|
|
await page.locator('button[data-filter="ai"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'ai')).toBe(true);
|
|
});
|
|
|
|
test('Switching back to "All" restores all projects', async ({ page }) => {
|
|
// Filter to web first
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Switch back to all
|
|
await page.locator('button[data-filter="all"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
await expect(visibleCards).toHaveCount(8);
|
|
});
|
|
|
|
test('Only one filter button is active at a time', async ({ page }) => {
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(300);
|
|
|
|
const activeButtons = page.locator('button[data-filter][aria-selected="true"]');
|
|
await expect(activeButtons).toHaveCount(1);
|
|
await expect(activeButtons.first()).toHaveAttribute('data-filter', 'web');
|
|
});
|
|
|
|
test('Case study modal opens when "View Case Study" is clicked', async ({ page }) => {
|
|
const firstDetailsBtn = page.locator('.view-details-btn').first();
|
|
await firstDetailsBtn.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const modal = page.locator('#case-study-modal');
|
|
await expect(modal).not.toHaveClass(/hidden/);
|
|
await expect(modal).toBeVisible();
|
|
|
|
// Modal has a title
|
|
await expect(page.locator('#modal-title')).toBeVisible();
|
|
});
|
|
|
|
test('Case study modal closes when close button is clicked', async ({ page }) => {
|
|
await page.locator('.view-details-btn').first().click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.locator('#close-modal-btn').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#case-study-modal')).toHaveClass(/hidden/);
|
|
});
|
|
|
|
test('Case study modal closes on Escape key', async ({ page }) => {
|
|
await page.locator('.view-details-btn').first().click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#case-study-modal')).toHaveClass(/hidden/);
|
|
});
|
|
|
|
test('Portfolio filters work on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.project-card').first()).toBeVisible();
|
|
|
|
// Filter tabs should be visible (flex-wrap layout)
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible();
|
|
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 8. Blog Markdown Rendering
|
|
// ============================================================
|
|
test.describe('Blog Rendering', () => {
|
|
test('Blog listing page displays articles', async ({ page }) => {
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
// Blog listing should have links to posts or a "no posts" message
|
|
const postLinks = page.locator('a[href^="/blog/"]');
|
|
const articleCards = page.locator('article');
|
|
const totalContent = (await postLinks.count()) + (await articleCards.count());
|
|
|
|
// Either posts exist or page loads cleanly
|
|
const bodyText = await page.locator('body').innerText();
|
|
expect(bodyText.trim().length).toBeGreaterThan(50);
|
|
});
|
|
|
|
test('Navigating to a blog post renders markdown content', async ({ page }) => {
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
const firstPostLink = page.locator('a[href^="/blog/"]').first();
|
|
const hasPost = (await firstPostLink.count()) > 0;
|
|
|
|
if (!hasPost) {
|
|
test.skip(); // No blog posts in content collection - skip gracefully
|
|
return;
|
|
}
|
|
|
|
await firstPostLink.click();
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Page must have an h1 title
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
// Page must have substantial content (markdown rendered to paragraphs)
|
|
const paragraphs = page.locator('p');
|
|
const pCount = await paragraphs.count();
|
|
expect(pCount).toBeGreaterThan(0);
|
|
|
|
// Should have prose content area
|
|
const content = await page.locator('body').innerText();
|
|
expect(content.trim().length).toBeGreaterThan(100);
|
|
});
|
|
|
|
test('Blog post page has proper heading hierarchy', async ({ page }) => {
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
const firstPostLink = page.locator('a[href^="/blog/"]').first();
|
|
if ((await firstPostLink.count()) === 0) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await firstPostLink.click();
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Must have exactly one h1
|
|
const h1Count = await page.locator('h1').count();
|
|
expect(h1Count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('Blog post code blocks render with pre/code tags', async ({ page }) => {
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
const firstPostLink = page.locator('a[href^="/blog/"]').first();
|
|
if ((await firstPostLink.count()) === 0) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await firstPostLink.click();
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
const htmlContent = await page.content();
|
|
// Code blocks use <pre> tags in markdown rendering - acceptable if absent (post may have no code)
|
|
const hasPreTag = htmlContent.includes('<pre') || htmlContent.includes('<code');
|
|
// Just verify the page rendered properly (code blocks optional per post content)
|
|
expect(htmlContent.length).toBeGreaterThan(500);
|
|
|
|
await page.screenshot({
|
|
path: 'tests/screenshots/blog-post-rendered.png',
|
|
fullPage: true,
|
|
});
|
|
});
|
|
|
|
test('Blog listing renders on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 9. Console Error Monitoring (comprehensive - all pages, all known errors)
|
|
// ============================================================
|
|
test.describe('Console Error Monitoring', () => {
|
|
test('No critical JavaScript errors across all pages', async ({ page }) => {
|
|
const errorLog: { url: string; error: string }[] = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errorLog.push({ url: page.url(), error: msg.text() });
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', (err) => {
|
|
errorLog.push({ url: page.url(), error: err.message });
|
|
});
|
|
|
|
const testPages = ALL_PAGES.filter((p) => p.url !== '/nonexistent-page-xyz');
|
|
|
|
for (const p of testPages) {
|
|
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(500); // Allow async scripts to run
|
|
}
|
|
|
|
const critical = errorLog.filter(
|
|
(e) =>
|
|
!e.error.includes('favicon') &&
|
|
!e.error.includes('livereload') &&
|
|
!e.error.includes('DevTools') &&
|
|
!e.error.includes('manifest') &&
|
|
!e.error.includes('net::ERR_ABORTED')
|
|
);
|
|
|
|
if (critical.length > 0) {
|
|
console.error('CRITICAL CONSOLE ERRORS FOUND:');
|
|
critical.forEach((e) => console.error(` [${e.url}] ${e.error}`));
|
|
}
|
|
|
|
expect(critical, `Found ${critical.length} critical console error(s)`).toHaveLength(0);
|
|
});
|
|
|
|
test('No failed network requests (4xx/5xx) for critical assets', async ({ page }) => {
|
|
const failedRequests: { url: string; status: number }[] = [];
|
|
|
|
page.on('response', (response) => {
|
|
const status = response.status();
|
|
const url = response.url();
|
|
|
|
// Only track failures for page-relative assets (JS, CSS, fonts, images from same origin)
|
|
if (
|
|
status >= 400 &&
|
|
(url.includes('.js') ||
|
|
url.includes('.css') ||
|
|
url.includes('.woff') ||
|
|
url.includes('.woff2'))
|
|
) {
|
|
failedRequests.push({ url, status });
|
|
}
|
|
});
|
|
|
|
await page.goto('/', { waitUntil: 'networkidle' });
|
|
|
|
if (failedRequests.length > 0) {
|
|
console.warn('Failed asset requests:', failedRequests);
|
|
}
|
|
|
|
expect(failedRequests).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 10. Header Scroll Behavior
|
|
// ============================================================
|
|
test.describe('Header Scroll Behavior', () => {
|
|
test('Header gets shadow class after scrolling down', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const header = page.locator('#main-header');
|
|
await expect(header).not.toHaveClass(/header-scrolled/);
|
|
|
|
// Scroll down
|
|
await page.evaluate(() => window.scrollTo(0, 100));
|
|
await page.waitForTimeout(200);
|
|
|
|
await expect(header).toHaveClass(/header-scrolled/);
|
|
});
|
|
|
|
test('Header shadow is removed when scrolled back to top', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 200));
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await page.waitForTimeout(200);
|
|
|
|
await expect(page.locator('#main-header')).not.toHaveClass(/header-scrolled/);
|
|
});
|
|
});
|