// E2E testing skill for Pawel Lipowczan portfolio project (Playwright + React/Vite). Use when user wants to create new E2E tests, debug flaky tests, extend test coverage, verify test completeness for features, or run/interpret test results. Covers navigation, forms, blog, SEO, accessibility (WCAG 2.1 AA), responsiveness. References docs/portfolio/testing/{README.md,TESTING_QUICKSTART.md}. Complements portfolio-code-review skill.
| name | portfolio-testing |
| description | E2E testing skill for Pawel Lipowczan portfolio project (Playwright + React/Vite). Use when user wants to create new E2E tests, debug flaky tests, extend test coverage, verify test completeness for features, or run/interpret test results. Covers navigation, forms, blog, SEO, accessibility (WCAG 2.1 AA), responsiveness. References docs/portfolio/testing/{README.md,TESTING_QUICKSTART.md}. Complements portfolio-code-review skill. |
| license | Apache-2.0 |
E2E testing skill for Pawel Lipowczan portfolio project using Playwright.
Before writing tests, familiarize yourself with:
docs/portfolio/testing/README.md - Full test documentationdocs/portfolio/testing/TESTING_QUICKSTART.md - Quick start guidetests/utils/test-helpers.js - Helper functionsplaywright.config.js - Test configurationStack: React 19 + Vite 7 + Tailwind CSS 3 + Framer Motion 12 + React Router 7
Test Framework: Playwright 1.56.1 (Chromium, Firefox, WebKit + Mobile viewports)
User request → What type?
├── "Create tests for [feature]" → New Test Workflow
├── "Test is failing/flaky" → Debug Workflow
├── "Add more test coverage" → Extend Coverage Workflow
├── "Run tests" → Execute & Interpret Workflow
└── "Verify tests for PR" → Verification Workflow
references/test-patterns.mdtests/utils/test-helpers.jsnpm run test:headed to verifynpm run test:debug to step throughreferences/debugging-guide.md for common issuesnpm testnpm test # Run all tests (3 browsers)
npm run test:headed # Visible browser for debugging
npm run test:ui # Interactive Playwright UI
npm run test:debug # Step-through debugging
npm run test:chrome # Chromium only (faster)
npm run test:mobile # Mobile viewports only
npm run test:report # View HTML report
import { test, expect } from "@playwright/test";
test.describe('Navigation - [Feature]', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
});
test('desktop menu navigates to [section]', async ({ page }) => {
await page.click('nav >> text=[Menu Item]');
await page.waitForSelector('#[section-id]', { state: 'visible' });
await expect(page.locator('#[section-id]')).toBeInViewport();
});
test('mobile menu works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.reload(); // Viewport must be set before navigation
await page.click('[aria-label="Toggle menu"]');
await expect(page.locator('.mobile-menu')).toBeVisible();
await page.click('nav >> text=[Menu Item]');
await expect(page.locator('.mobile-menu')).not.toBeVisible();
});
test('smooth scroll works', async ({ page }) => {
await page.click('nav >> text=Kontakt');
await page.waitForTimeout(500); // Wait for scroll animation
await expect(page.locator('#contact')).toBeInViewport();
});
test('mobile menu closes on Escape', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.reload();
await page.click('[aria-label="Toggle menu"]');
await expect(page.locator('.mobile-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.mobile-menu')).not.toBeVisible();
});
});
import { test, expect } from "@playwright/test";
test.describe('Contact Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000/#contact');
await page.waitForSelector('form#contact-form', { state: 'visible' });
});
test('shows error for empty required fields', async ({ page }) => {
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
test('shows error for invalid email', async ({ page }) => {
await page.fill('input[name="name"]', 'Test User');
await page.fill('input[name="email"]', 'invalid-email');
await page.fill('textarea[name="message"]', 'Test message');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toContainText('email');
});
test('form has accessible labels', async ({ page }) => {
const nameInput = page.locator('input[name="name"]');
const label = await nameInput.getAttribute('aria-label') ||
await page.locator(`label[for="${await nameInput.getAttribute('id')}"]`).textContent();
expect(label).toBeTruthy();
});
test('tab navigation through fields works', async ({ page }) => {
await page.click('input[name="name"]');
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => document.activeElement?.name);
expect(focusedElement).toBe('email');
});
});
import { test, expect } from "@playwright/test";
test.describe('Blog', () => {
test('blog listing shows posts', async ({ page }) => {
await page.goto('http://localhost:3000/blog');
await expect(page.locator('.blog-post-card').first()).toBeVisible();
});
test('post card shows required info', async ({ page }) => {
await page.goto('http://localhost:3000/blog');
const card = page.locator('.blog-post-card').first();
await expect(card.locator('img')).toBeVisible(); // Image
await expect(card.locator('h2, h3')).toBeVisible(); // Title
await expect(card.locator('.date, time')).toBeVisible(); // Date
});
test('post page renders markdown content', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await expect(page.locator('article')).toBeVisible();
await expect(page.locator('article h1')).toBeVisible();
await expect(page.locator('article .prose, article p')).toBeVisible();
});
test('post page shows frontmatter data', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await expect(page.locator('.reading-time, [data-reading-time]')).toBeVisible();
await expect(page.locator('.tags, [data-tags]')).toBeVisible();
});
test('back to blog navigation works', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await page.click('text=Wróć, text=Blog, a[href="/blog"]');
await expect(page).toHaveURL(/\/blog\/?$/);
});
});
import { test, expect } from "@playwright/test";
test.describe('SEO - [Page Name]', () => {
test('has unique title', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
await page.waitForFunction(() => document.title !== 'Loading...');
const title = await page.title();
expect(title).toContain('[Expected keyword]');
expect(title.length).toBeGreaterThan(10);
expect(title.length).toBeLessThan(70);
});
test('has meta description', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const description = await page.getAttribute('meta[name="description"]', 'content');
expect(description).toBeTruthy();
expect(description.length).toBeGreaterThan(50);
expect(description.length).toBeLessThan(160);
});
test('has OG tags', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const ogTitle = await page.getAttribute('meta[property="og:title"]', 'content');
const ogDescription = await page.getAttribute('meta[property="og:description"]', 'content');
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
const ogUrl = await page.getAttribute('meta[property="og:url"]', 'content');
expect(ogTitle).toBeTruthy();
expect(ogDescription).toBeTruthy();
expect(ogImage).toMatch(/\.(png|jpg|jpeg|webp)$/i);
expect(ogUrl).toMatch(/^https?:\/\//);
});
test('has JSON-LD structured data', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const jsonLd = await page.$eval(
'script[type="application/ld+json"]',
el => JSON.parse(el.textContent)
);
expect(jsonLd['@type']).toBeTruthy();
});
test('has canonical URL', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical).toMatch(/^https?:\/\//);
});
});
import { test, expect } from "@playwright/test";
test.describe('Accessibility - [Component]', () => {
test('keyboard navigation works', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.tagName);
expect(['A', 'BUTTON', 'INPUT']).toContain(focused);
});
test('focus indicators are visible', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
const outline = await focusedElement.evaluate(el =>
getComputedStyle(el).outline || getComputedStyle(el).boxShadow
);
expect(outline).not.toBe('none');
});
test('images have alt text', async ({ page }) => {
await page.goto('http://localhost:3000');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt');
expect(alt).toBeTruthy();
}
});
test('only one H1 per page', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const h1Count = await page.locator('h1').count();
expect(h1Count).toBe(1);
});
test('heading hierarchy is correct', async ({ page }) => {
await page.goto('http://localhost:3000');
const headings = await page.evaluate(() => {
return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
.map(h => parseInt(h.tagName[1]));
});
// Check no level is skipped (e.g., h1 -> h3 without h2)
for (let i = 1; i < headings.length; i++) {
expect(headings[i] - headings[i-1]).toBeLessThanOrEqual(1);
}
});
test('form labels are properly linked', async ({ page }) => {
await page.goto('http://localhost:3000/#contact');
const inputs = page.locator('input:not([type="hidden"]), textarea, select');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledby = await input.getAttribute('aria-labelledby');
const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
expect(hasLabel || ariaLabel || ariaLabelledby).toBeTruthy();
}
});
});
import { test, expect } from "@playwright/test";
const viewports = {
mobile: { width: 375, height: 667 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1920, height: 1080 }
};
test.describe('Responsiveness', () => {
for (const [name, size] of Object.entries(viewports)) {
test(`content is readable on ${name}`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('http://localhost:3000');
// Check no horizontal overflow
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
expect(bodyWidth).toBeLessThanOrEqual(size.width);
// Check main content is visible
await expect(page.locator('main, #hero, .hero')).toBeVisible();
});
test(`images scale correctly on ${name}`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('http://localhost:3000');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < Math.min(count, 5); i++) {
const box = await images.nth(i).boundingBox();
if (box) {
expect(box.width).toBeLessThanOrEqual(size.width);
}
}
});
}
});
Available in tests/utils/test-helpers.js:
waitForSection(page, sectionId) - Wait for section visibilitycheckFormAccessibility(page) - Verify form accessibilitytestResponsiveLayout(page, viewports) - Test across viewportsverifyMetaTags(page, expected) - Check SEO meta tagsnavigateToSection(page, sectionName) - Navigate via menucheckMobileMenu(page) - Test mobile menu behaviorscrollToElement(page, selector) - Smooth scroll helperUser: "Dodaj testy dla nowego filtrowania projektów"
Steps:
tests/home.spec.jsnpm run test:headedtest.describe('Projects Filtering', () => {
test('filter buttons are visible', async ({ page }) => {
await page.goto('http://localhost:3000/#projects');
await expect(page.locator('.filter-buttons')).toBeVisible();
});
test('clicking filter shows matching projects', async ({ page }) => {
await page.goto('http://localhost:3000/#projects');
await page.click('button:has-text("React")');
const projects = page.locator('.project-card');
const count = await projects.count();
for (let i = 0; i < count; i++) {
await expect(projects.nth(i)).toContainText('React');
}
});
});
User: "Test mobile menu failuje losowo"
Steps:
npm run test:debug to step throughreferences/debugging-guide.md:
// Before (flaky)
test('mobile menu', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.setViewportSize({ width: 375, height: 667 });
await page.click('[aria-label="Toggle menu"]');
});
// After (stable)
test('mobile menu', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('http://localhost:3000');
await page.waitForSelector('[aria-label="Toggle menu"]', { state: 'visible' });
await page.click('[aria-label="Toggle menu"]');
await page.waitForSelector('.mobile-menu', { state: 'visible' });
});
User: "Zweryfikuj SEO dla nowego posta o automatyzacji"
Steps:
automatyzacja-email)tests/blog.spec.js:test.describe('SEO - Blog Post: Automatyzacja Email', () => {
const postUrl = 'http://localhost:3000/blog/automatyzacja-email';
test('has correct title', async ({ page }) => {
await page.goto(postUrl);
const title = await page.title();
expect(title).toContain('Automatyzacja');
});
test('has OG image', async ({ page }) => {
await page.goto(postUrl);
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
expect(ogImage).toMatch(/og-automatyzacja-email\.webp/);
});
test('has BlogPosting schema', async ({ page }) => {
await page.goto(postUrl);
const jsonLd = await page.$eval(
'script[type="application/ld+json"]',
el => JSON.parse(el.textContent)
);
expect(jsonLd['@type']).toBe('BlogPosting');
});
});
test('sitemap includes new post', async ({ page }) => {
const response = await page.goto('http://localhost:3000/sitemap.xml');
const content = await response.text();
expect(content).toContain('/blog/automatyzacja-email');
});
| portfolio-code-review | portfolio-testing |
|---|---|
| Reviews code changes | Verifies runtime behavior |
| Static analysis | E2E tests |
| Checks conventions | Checks functionality |
| Pre-merge review | Post-implementation verification |
Workflow:
portfolio-code-review reviews code quality, edge cases, conventionsportfolio-testing creates/runs tests to verify behaviorWhen to use which:
After running tests, generate report:
# E2E Test Report - [Feature/Date]
## Summary
- **Tests run:** [number]
- **Passed:** [number]
- **Failed:** [number]
- **Skipped:** [number]
## Test Coverage
### Navigation
- [x] Desktop menu: PASS
- [x] Mobile menu: PASS
- [ ] Smooth scroll: FAIL - timeout on #contact
### Forms
- [x] Validation: PASS
- [x] Accessibility: PASS
### Blog
- [x] Listing: PASS
- [x] Single post: PASS
### SEO
- [x] Meta tags: PASS
- [ ] OG image: FAIL - 404 for og-[slug].webp
## Failed Tests
### smooth scroll to contact section
**Error:** Timeout 30000ms exceeded
**Screenshot:** [link to report]
**Likely cause:** Animation timing or element not found
**Suggested fix:** Add explicit wait or check selector
### OG image for new post
**Error:** 404 for /images/og-new-post.webp
**Likely cause:** OG image not created
**Suggested fix:** Run `npm run img:convert` or create WebP image
## Next Steps
1. Fix smooth scroll timing
2. Create missing OG image
3. Re-run tests: `npm test`
test:headed during development (see what's happening)