Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:16:40 +08:00
commit f125e90b9f
370 changed files with 67769 additions and 0 deletions

View File

@@ -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: {},
},
};

View File

@@ -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: {},
},
};

View File

@@ -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 */

View File

@@ -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;
}
}
*/

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});
}
}

View File

@@ -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'),
});

View File

@@ -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,
};
}

View File

@@ -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
}
}
});
});