Initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* PostCSS Configuration for Tailwind CSS v3
|
||||
*
|
||||
* Compatible with: Tailwind CSS >=3.0.0 <4.0.0
|
||||
* Generated by: playwright-e2e-automation skill
|
||||
*/
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
// Tailwind CSS v3 uses 'tailwindcss' as plugin name
|
||||
tailwindcss: {},
|
||||
|
||||
// Autoprefixer adds vendor prefixes for browser compatibility
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* PostCSS Configuration for Tailwind CSS v4
|
||||
*
|
||||
* Compatible with: Tailwind CSS >=4.0.0
|
||||
*
|
||||
* Breaking changes from v3:
|
||||
* - Plugin name changed from 'tailwindcss' to '@tailwindcss/postcss'
|
||||
* - Improved performance with new architecture
|
||||
* - Better integration with build tools
|
||||
*
|
||||
* Migration guide: https://tailwindcss.com/docs/upgrade-guide
|
||||
* Generated by: playwright-e2e-automation skill
|
||||
*/
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
// Tailwind CSS v4 uses '@tailwindcss/postcss' as plugin name
|
||||
// This is a BREAKING CHANGE from v3
|
||||
'@tailwindcss/postcss': {},
|
||||
|
||||
// Autoprefixer adds vendor prefixes for browser compatibility
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Tailwind CSS v3 Syntax
|
||||
*
|
||||
* This template uses the classic @tailwind directive syntax
|
||||
* Compatible with: Tailwind CSS >=3.0.0 <4.0.0
|
||||
*
|
||||
* Generated by: playwright-e2e-automation skill
|
||||
*/
|
||||
|
||||
/* Base styles, CSS resets, and browser normalization */
|
||||
@tailwind base;
|
||||
|
||||
/* Component classes and utilities */
|
||||
@tailwind components;
|
||||
|
||||
/* Utility classes (spacing, colors, typography, etc.) */
|
||||
@tailwind utilities;
|
||||
|
||||
/* ========== CUSTOM STYLES ========== */
|
||||
/* Add your custom CSS below this line */
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Tailwind CSS v4 Syntax
|
||||
*
|
||||
* This template uses the new @import syntax introduced in Tailwind v4
|
||||
* Compatible with: Tailwind CSS >=4.0.0
|
||||
*
|
||||
* Breaking changes from v3:
|
||||
* - @tailwind directives replaced with single @import
|
||||
* - PostCSS plugin changed to @tailwindcss/postcss
|
||||
* - Improved performance and smaller bundle size
|
||||
*
|
||||
* Migration guide: https://tailwindcss.com/docs/upgrade-guide
|
||||
* Generated by: playwright-e2e-automation skill
|
||||
*/
|
||||
|
||||
/* Import Tailwind CSS (base, components, utilities all included) */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ========== CUSTOM STYLES ========== */
|
||||
/* Add your custom CSS below this line */
|
||||
|
||||
/* Example: Custom utility classes using @layer */
|
||||
/*
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* Example: Custom components using @layer */
|
||||
/*
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700;
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Vanilla CSS Template (No CSS Framework)
|
||||
*
|
||||
* This template is used when no CSS framework is detected
|
||||
* or when Tailwind CSS is not installed.
|
||||
*
|
||||
* Generated by: playwright-e2e-automation skill
|
||||
*/
|
||||
|
||||
/* ========== CSS RESET ========== */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ========== BASE STYLES ========== */
|
||||
:root {
|
||||
/* Color palette */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-dark: #2563eb;
|
||||
--color-text: #1f2937;
|
||||
--color-text-light: #6b7280;
|
||||
--color-background: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ========== TYPOGRAPHY ========== */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ========== FORM ELEMENTS ========== */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ========== UTILITY CLASSES ========== */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
.mt-3 {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ========== CUSTOM STYLES ========== */
|
||||
/* Add your custom CSS below this line */
|
||||
@@ -0,0 +1,127 @@
|
||||
import { chromium, FullConfig } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Global Setup
|
||||
* Runs once before all tests
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Ensure dev server is ready
|
||||
* - Create screenshot directories
|
||||
* - Perform any global authentication
|
||||
* - Set up test database (if needed)
|
||||
*/
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log('\n🚀 Starting global setup...\n');
|
||||
|
||||
// 1. Create screenshot directories
|
||||
createScreenshotDirectories();
|
||||
|
||||
// 2. Verify dev server is accessible (webServer config handles startup)
|
||||
const baseURL = config.projects[0].use.baseURL || 'http://localhost:{{PORT}}';
|
||||
await verifyServer(baseURL);
|
||||
|
||||
// 3. Optional: Perform global authentication
|
||||
// await performAuthentication(config);
|
||||
|
||||
console.log('✅ Global setup complete\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create necessary screenshot directories
|
||||
*/
|
||||
function createScreenshotDirectories() {
|
||||
const directories = [
|
||||
'screenshots/current',
|
||||
'screenshots/baselines',
|
||||
'screenshots/diffs',
|
||||
'test-results',
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
const dirPath = path.join(process.cwd(), dir);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
console.log(`📁 Created directory: ${dir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify dev server is accessible
|
||||
*/
|
||||
async function verifyServer(baseURL: string, maxRetries = 30) {
|
||||
console.log(`🔍 Verifying server at ${baseURL}...`);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
const response = await page.goto(baseURL, { timeout: 5000 });
|
||||
|
||||
if (response && response.ok()) {
|
||||
console.log(`✅ Server is ready at ${baseURL}`);
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
} catch (error) {
|
||||
// Server not ready yet, wait and retry
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} else {
|
||||
throw new Error(
|
||||
`Server at ${baseURL} is not accessible after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Perform global authentication
|
||||
* Saves authentication state to be reused across all tests
|
||||
*/
|
||||
async function performAuthentication(config: FullConfig) {
|
||||
// Only run if authentication is needed
|
||||
if (!process.env.AUTH_USERNAME || !process.env.AUTH_PASSWORD) {
|
||||
console.log('⏭️ Skipping authentication (no credentials provided)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔐 Performing global authentication...');
|
||||
|
||||
const baseURL = config.projects[0].use.baseURL || 'http://localhost:{{PORT}}';
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Navigate to login page
|
||||
await page.goto(`${baseURL}/login`);
|
||||
|
||||
// Fill in credentials
|
||||
await page.getByLabel('Username').fill(process.env.AUTH_USERNAME);
|
||||
await page.getByLabel('Password').fill(process.env.AUTH_PASSWORD);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
// Wait for successful login (adjust selector as needed)
|
||||
await page.waitForURL(`${baseURL}/dashboard`, { timeout: 10000 });
|
||||
|
||||
// Save authentication state
|
||||
await context.storageState({ path: 'auth.json' });
|
||||
|
||||
console.log('✅ Authentication successful, state saved to auth.json');
|
||||
} catch (error) {
|
||||
console.error('❌ Authentication failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
import { generateManifest } from '../utils/screenshot-helper';
|
||||
|
||||
/**
|
||||
* Global Teardown
|
||||
* Runs once after all tests complete
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Generate screenshot manifest
|
||||
* - Clean up temporary files (if needed)
|
||||
* - Generate summary report
|
||||
* - Perform any cleanup tasks
|
||||
*/
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log('\n🏁 Starting global teardown...\n');
|
||||
|
||||
// 1. Generate screenshot manifest
|
||||
try {
|
||||
generateManifest();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to generate screenshot manifest:', error);
|
||||
}
|
||||
|
||||
// 2. Generate test summary
|
||||
generateSummary();
|
||||
|
||||
// 3. Optional: Clean up temporary files
|
||||
// cleanupTempFiles();
|
||||
|
||||
console.log('\n✅ Global teardown complete\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test execution summary
|
||||
*/
|
||||
function generateSummary() {
|
||||
console.log('\n📊 Test Execution Summary:');
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
// Test results are available through Playwright's built-in reporters
|
||||
// This is just a placeholder for custom summary logic
|
||||
|
||||
console.log('✅ Check playwright-report/ for detailed results');
|
||||
console.log('✅ Screenshots available in screenshots/current/');
|
||||
console.log('✅ Test results available in test-results/');
|
||||
|
||||
console.log('\n💡 Next steps:');
|
||||
console.log(' 1. Review screenshots for visual issues');
|
||||
console.log(' 2. Compare with baselines if available');
|
||||
console.log(' 3. Run visual analysis: npm run analyze:visual');
|
||||
console.log(' 4. Generate fix recommendations if issues found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional: Clean up temporary files
|
||||
*/
|
||||
function cleanupTempFiles() {
|
||||
// Add cleanup logic here if needed
|
||||
// For example: remove old screenshots, clear cache, etc.
|
||||
console.log('🧹 Cleaning up temporary files...');
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* {{PAGE_NAME}} Page Object Model
|
||||
*
|
||||
* Represents: {{PAGE_DESCRIPTION}}
|
||||
* URL: {{PAGE_URL}}
|
||||
* Generated: {{GENERATED_DATE}}
|
||||
*/
|
||||
|
||||
export class {{PAGE_CLASS_NAME}} {
|
||||
readonly page: Page;
|
||||
|
||||
// Locators - Using semantic selectors (getByRole, getByLabel, getByText)
|
||||
{{#LOCATORS}}
|
||||
readonly {{LOCATOR_NAME}}: Locator;
|
||||
{{/LOCATORS}}
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
|
||||
// Initialize locators
|
||||
{{#LOCATORS}}
|
||||
this.{{LOCATOR_NAME}} = page.{{SELECTOR}};
|
||||
{{/LOCATORS}}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to this page
|
||||
*/
|
||||
async goto() {
|
||||
await this.page.goto('{{PAGE_URL}}');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for page to be ready
|
||||
*/
|
||||
async waitForReady() {
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
{{#READY_INDICATORS}}
|
||||
await this.{{INDICATOR}}.waitFor({ state: 'visible' });
|
||||
{{/READY_INDICATORS}}
|
||||
}
|
||||
|
||||
{{#METHODS}}
|
||||
/**
|
||||
* {{METHOD_DESCRIPTION}}
|
||||
{{#PARAMS}}
|
||||
* @param {{PARAM_NAME}} - {{PARAM_DESCRIPTION}}
|
||||
{{/PARAMS}}
|
||||
*/
|
||||
async {{METHOD_NAME}}({{PARAMS_SIGNATURE}}) {
|
||||
{{METHOD_BODY}}
|
||||
}
|
||||
|
||||
{{/METHODS}}
|
||||
|
||||
/**
|
||||
* Get page title
|
||||
*/
|
||||
async getTitle(): Promise<string> {
|
||||
return await this.page.title();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current URL
|
||||
*/
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
return this.page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take screenshot of this page
|
||||
* @param name - Screenshot filename
|
||||
*/
|
||||
async screenshot(name: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
await this.page.screenshot({
|
||||
path: `screenshots/current/{{PAGE_NAME_KEBAB}}-${name}-${timestamp}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright Configuration
|
||||
* Generated by playwright-e2e-automation skill
|
||||
*
|
||||
* Framework: {{FRAMEWORK_NAME}}
|
||||
* Base URL: {{BASE_URL}}
|
||||
* Generated: {{GENERATED_DATE}}
|
||||
*/
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/specs',
|
||||
|
||||
/**
|
||||
* Maximum time one test can run for
|
||||
*/
|
||||
timeout: {{TIMEOUT}},
|
||||
|
||||
/**
|
||||
* Test execution settings
|
||||
*/
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
/**
|
||||
* Reporter configuration
|
||||
* CI: GitHub Actions reporter
|
||||
* Local: HTML reporter with screenshots
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? 'github'
|
||||
: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
],
|
||||
|
||||
/**
|
||||
* Shared settings for all projects
|
||||
*/
|
||||
use: {
|
||||
/* Base URL for navigation */
|
||||
baseURL: '{{BASE_URL}}',
|
||||
|
||||
/* Collect trace on first retry */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Screenshot settings */
|
||||
screenshot: {
|
||||
mode: 'only-on-failure',
|
||||
fullPage: true,
|
||||
},
|
||||
|
||||
/* Video settings */
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* Maximum time each action can take */
|
||||
actionTimeout: 10000,
|
||||
|
||||
/* Navigation timeout */
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
/**
|
||||
* Browser and device configurations
|
||||
*/
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium-desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox-desktop',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-desktop',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'mobile-safari',
|
||||
use: {
|
||||
...devices['iPhone 13'],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'tablet',
|
||||
use: {
|
||||
...devices['iPad Pro'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Web server configuration
|
||||
* Starts dev server before running tests
|
||||
*/
|
||||
webServer: {
|
||||
command: '{{DEV_SERVER_COMMAND}}',
|
||||
url: '{{BASE_URL}}',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
|
||||
/**
|
||||
* Output directories
|
||||
*/
|
||||
outputDir: 'test-results',
|
||||
|
||||
/**
|
||||
* Global setup/teardown
|
||||
*/
|
||||
globalSetup: require.resolve('./tests/setup/global-setup.ts'),
|
||||
globalTeardown: require.resolve('./tests/setup/global-teardown.ts'),
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Screenshot Helper Utilities
|
||||
* Provides consistent screenshot capture with metadata
|
||||
*/
|
||||
|
||||
export interface ScreenshotMetadata {
|
||||
path: string;
|
||||
context: string;
|
||||
timestamp: string;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
url: string;
|
||||
testName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot with context metadata
|
||||
*
|
||||
* @param page - Playwright page object
|
||||
* @param name - Screenshot name (will be kebab-cased)
|
||||
* @param context - Description of what the screenshot shows
|
||||
* @returns Metadata about the captured screenshot
|
||||
*/
|
||||
export async function captureWithContext(
|
||||
page: Page,
|
||||
name: string,
|
||||
context: string
|
||||
): Promise<ScreenshotMetadata> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const viewport = page.viewportSize() || { width: 1280, height: 720 };
|
||||
const url = page.url();
|
||||
|
||||
// Ensure screenshots directory exists
|
||||
const screenshotDir = path.join(process.cwd(), 'screenshots', 'current');
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate filename
|
||||
const filename = `${name}-${timestamp}.png`;
|
||||
const screenshotPath = path.join(screenshotDir, filename);
|
||||
|
||||
// Wait for network idle before capturing
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Capture screenshot
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// Create metadata
|
||||
const metadata: ScreenshotMetadata = {
|
||||
path: screenshotPath,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
viewport,
|
||||
url,
|
||||
testName: process.env.PLAYWRIGHT_TEST_NAME,
|
||||
};
|
||||
|
||||
// Save metadata alongside screenshot
|
||||
const metadataPath = screenshotPath.replace('.png', '.json');
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
console.log(`📸 Screenshot captured: ${filename}`);
|
||||
console.log(` Context: ${context}`);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture element screenshot with context
|
||||
*
|
||||
* @param page - Playwright page object
|
||||
* @param selector - Element selector
|
||||
* @param name - Screenshot name
|
||||
* @param context - Description
|
||||
*/
|
||||
export async function captureElement(
|
||||
page: Page,
|
||||
selector: string,
|
||||
name: string,
|
||||
context: string
|
||||
): Promise<ScreenshotMetadata> {
|
||||
const element = page.locator(selector);
|
||||
await element.waitFor({ state: 'visible' });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const viewport = page.viewportSize() || { width: 1280, height: 720 };
|
||||
const url = page.url();
|
||||
|
||||
const screenshotDir = path.join(process.cwd(), 'screenshots', 'current');
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${name}-element-${timestamp}.png`;
|
||||
const screenshotPath = path.join(screenshotDir, filename);
|
||||
|
||||
await element.screenshot({
|
||||
path: screenshotPath,
|
||||
});
|
||||
|
||||
const metadata: ScreenshotMetadata = {
|
||||
path: screenshotPath,
|
||||
context: `${context} (element: ${selector})`,
|
||||
timestamp: new Date().toISOString(),
|
||||
viewport,
|
||||
url,
|
||||
testName: process.env.PLAYWRIGHT_TEST_NAME,
|
||||
};
|
||||
|
||||
const metadataPath = screenshotPath.replace('.png', '.json');
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
console.log(`📸 Element screenshot captured: ${filename}`);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture comparison screenshots (before/after)
|
||||
*
|
||||
* @param page - Playwright page object
|
||||
* @param name - Base name for screenshots
|
||||
* @param actionCallback - Action to perform between screenshots
|
||||
*/
|
||||
export async function captureComparison(
|
||||
page: Page,
|
||||
name: string,
|
||||
actionCallback: () => Promise<void>
|
||||
): Promise<{ before: ScreenshotMetadata; after: ScreenshotMetadata }> {
|
||||
const before = await captureWithContext(page, `${name}-before`, 'State before action');
|
||||
|
||||
await actionCallback();
|
||||
|
||||
const after = await captureWithContext(page, `${name}-after`, 'State after action');
|
||||
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshots across multiple viewports
|
||||
*
|
||||
* @param page - Playwright page object
|
||||
* @param name - Base name for screenshots
|
||||
* @param viewports - Array of viewport configurations
|
||||
*/
|
||||
export async function captureViewports(
|
||||
page: Page,
|
||||
name: string,
|
||||
viewports: Array<{ name: string; width: number; height: number }>
|
||||
): Promise<ScreenshotMetadata[]> {
|
||||
const screenshots: ScreenshotMetadata[] = [];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
|
||||
// Wait for responsive changes to settle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const metadata = await captureWithContext(
|
||||
page,
|
||||
`${name}-${viewport.name}`,
|
||||
`${viewport.width}x${viewport.height} viewport`
|
||||
);
|
||||
|
||||
screenshots.push(metadata);
|
||||
}
|
||||
|
||||
return screenshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate screenshot manifest
|
||||
* Collects all screenshots and their metadata into a single manifest file
|
||||
*/
|
||||
export function generateManifest(): void {
|
||||
const screenshotDir = path.join(process.cwd(), 'screenshots', 'current');
|
||||
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
console.log('No screenshots directory found');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(screenshotDir);
|
||||
const metadataFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
const manifest = metadataFiles.map((file) => {
|
||||
const content = fs.readFileSync(path.join(screenshotDir, file), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
});
|
||||
|
||||
const manifestPath = path.join(screenshotDir, 'manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
console.log(`\n📋 Screenshot manifest generated: ${manifestPath}`);
|
||||
console.log(` Total screenshots: ${manifest.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare screenshot with baseline
|
||||
*
|
||||
* @param currentPath - Path to current screenshot
|
||||
* @param baselinePath - Path to baseline screenshot
|
||||
* @param diffPath - Path to save diff image
|
||||
* @param threshold - Difference threshold (0-1, default 0.2 = 20%)
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
currentPath: string,
|
||||
baselinePath: string,
|
||||
diffPath: string,
|
||||
threshold: number = 0.2
|
||||
): Promise<{ match: boolean; diffPercentage: number }> {
|
||||
// Note: This requires pixelmatch or Playwright's built-in comparison
|
||||
// For now, this is a placeholder showing the interface
|
||||
|
||||
console.log(`🔍 Comparing screenshots:`);
|
||||
console.log(` Current: ${currentPath}`);
|
||||
console.log(` Baseline: ${baselinePath}`);
|
||||
|
||||
// Implementation would use Playwright's toHaveScreenshot comparison
|
||||
// or a library like pixelmatch for pixel-level comparison
|
||||
|
||||
return {
|
||||
match: true,
|
||||
diffPercentage: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { {{PAGE_OBJECT_CLASS}} } from '../pages/{{PAGE_OBJECT_FILE}}';
|
||||
import { captureWithContext } from '../utils/screenshot-helper';
|
||||
|
||||
/**
|
||||
* {{TEST_SUITE_NAME}}
|
||||
*
|
||||
* Tests: {{TEST_DESCRIPTION}}
|
||||
* Generated: {{GENERATED_DATE}}
|
||||
*/
|
||||
|
||||
test.describe('{{TEST_SUITE_NAME}}', () => {
|
||||
let page: {{PAGE_OBJECT_CLASS}};
|
||||
|
||||
test.beforeEach(async ({ page: testPage }) => {
|
||||
page = new {{PAGE_OBJECT_CLASS}}(testPage);
|
||||
await page.goto();
|
||||
|
||||
// Capture initial page load
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
'{{TEST_SUITE_NAME_KEBAB}}-initial-load',
|
||||
'Page loaded successfully'
|
||||
);
|
||||
});
|
||||
|
||||
{{#TESTS}}
|
||||
test('{{TEST_NAME}}', async ({ page: testPage }) => {
|
||||
// Arrange: {{ARRANGE_DESCRIPTION}}
|
||||
{{#ARRANGE_STEPS}}
|
||||
{{STEP}}
|
||||
{{/ARRANGE_STEPS}}
|
||||
|
||||
// Capture pre-action state
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
'{{TEST_SUITE_NAME_KEBAB}}-{{TEST_NAME_KEBAB}}-before',
|
||||
'Before {{ACTION_DESCRIPTION}}'
|
||||
);
|
||||
|
||||
// Act: {{ACTION_DESCRIPTION}}
|
||||
{{ACTION_CODE}}
|
||||
|
||||
// Capture post-action state
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
'{{TEST_SUITE_NAME_KEBAB}}-{{TEST_NAME_KEBAB}}-after',
|
||||
'After {{ACTION_DESCRIPTION}}'
|
||||
);
|
||||
|
||||
// Assert: {{ASSERT_DESCRIPTION}}
|
||||
{{#ASSERTIONS}}
|
||||
await expect({{SELECTOR}}).{{MATCHER}};
|
||||
{{/ASSERTIONS}}
|
||||
|
||||
// Final state screenshot
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
'{{TEST_SUITE_NAME_KEBAB}}-{{TEST_NAME_KEBAB}}-final',
|
||||
'{{FINAL_STATE_DESCRIPTION}}'
|
||||
);
|
||||
});
|
||||
|
||||
{{/TESTS}}
|
||||
|
||||
test('should not have accessibility violations', async ({ page: testPage }) => {
|
||||
// Run accessibility audit
|
||||
const AxeBuilder = (await import('@axe-core/playwright')).default;
|
||||
|
||||
const accessibilityScanResults = await new AxeBuilder({ page: testPage })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
|
||||
.analyze();
|
||||
|
||||
// Capture page with any violations highlighted
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
'{{TEST_SUITE_NAME_KEBAB}}-accessibility-check',
|
||||
`Found ${accessibilityScanResults.violations.length} accessibility violations`
|
||||
);
|
||||
|
||||
// Fail if there are critical violations
|
||||
const criticalViolations = accessibilityScanResults.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious'
|
||||
);
|
||||
|
||||
expect(criticalViolations).toEqual([]);
|
||||
});
|
||||
|
||||
test('should display correctly across viewports', async ({ page: testPage }) => {
|
||||
const viewports = [
|
||||
{ name: 'desktop', width: 1280, height: 720 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'mobile', width: 375, height: 667 },
|
||||
];
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await testPage.setViewportSize(viewport);
|
||||
|
||||
// Wait for any responsive changes to settle
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Capture screenshot for each viewport
|
||||
await captureWithContext(
|
||||
testPage,
|
||||
`{{TEST_SUITE_NAME_KEBAB}}-responsive-${viewport.name}`,
|
||||
`${viewport.width}x${viewport.height} viewport`
|
||||
);
|
||||
|
||||
// Basic responsive checks
|
||||
await expect(testPage.locator('body')).toBeVisible();
|
||||
|
||||
// No horizontal scroll on mobile/tablet
|
||||
if (viewport.name !== 'desktop') {
|
||||
const scrollWidth = await testPage.evaluate(() => document.body.scrollWidth);
|
||||
const clientWidth = await testPage.evaluate(() => document.body.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); // Allow 1px tolerance
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user