diff --git a/.github/workflows/visual-tests.yml b/.github/workflows/visual-tests.yml index 94de36b26..f697c591c 100644 --- a/.github/workflows/visual-tests.yml +++ b/.github/workflows/visual-tests.yml @@ -8,6 +8,11 @@ on: jobs: visual-baseline: + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] timeout-minutes: 60 runs-on: ubuntu-latest continue-on-error: true @@ -27,12 +32,11 @@ jobs: rm -rf playwright-report/ rm -rf test-results/ - - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Run Playwright Visual Tests - run: npx playwright test e2e/flows/99-auth-ui-baseline.spec.ts e2e/flows/supplier-comparison-visual.spec.ts e2e/flows/31-promoflix-player.spec.ts e2e/tooltips-a11y.spec.ts e2e/optimized-image-visual.spec.ts e2e/routes/app/replenishment-grid-visual.spec.ts + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} e2e/flows/99-auth-ui-baseline.spec.ts e2e/flows/supplier-comparison-visual.spec.ts e2e/flows/31-promoflix-player.spec.ts e2e/tooltips-a11y.spec.ts e2e/optimized-image-visual.spec.ts e2e/routes/app/replenishment-grid-visual.spec.ts e2e/routes/app/novelty-grid-visual.spec.ts e2e/routes/app/novelty-card-variations.spec.ts env: CI: true @@ -40,7 +44,7 @@ jobs: if: always() uses: actions/upload-artifact@v5 with: - name: playwright-report + name: playwright-report-${{ matrix.shardIndex }} path: playwright-report/ retention-days: 30 diff --git a/e2e/routes/app/novelty-card-variations.spec.ts b/e2e/routes/app/novelty-card-variations.spec.ts new file mode 100644 index 000000000..cdd41bbb0 --- /dev/null +++ b/e2e/routes/app/novelty-card-variations.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Novelty Card Variations @mobile', () => { + test.beforeEach(async ({ context }) => { + // Mock the novelties API to provide specific edge cases + await context.route('**/functions/v1/novelties**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + novelty_id: 'var-1', + product_id: 'p-1', + product_name: 'Produto com título extremamente longo que deve ocupar pelo menos três ou quatro linhas no grid para testar o alinhamento e o truncamento de texto', + product_sku: 'SKU-LONG-TITLE', + product_image: 'https://placehold.co/400x400?text=Normal', + base_price: 150.50, + stock_quantity: 100, + stock_status: 'in-stock', + detected_at: new Date().toISOString(), + days_remaining: 30, + supplier_name: 'Fornecedor A', + category_name: 'Categoria Alpha' + }, + { + novelty_id: 'var-2', + product_id: 'p-2', + product_name: 'Preço sob consulta', + product_sku: 'SKU-QUERY-PRICE', + product_image: 'https://placehold.co/400x400?text=Price+Query', + base_price: 0, + stock_quantity: 50, + stock_status: 'low-stock', + detected_at: new Date().toISOString(), + days_remaining: 15, + supplier_name: 'Fornecedor B', + category_name: 'Categoria Beta' + }, + { + novelty_id: 'var-3', + product_id: 'p-3', + product_name: 'Imagem Ausente', + product_sku: 'SKU-NO-IMAGE', + product_image: null, + base_price: 89.90, + stock_quantity: 0, + stock_status: 'out-of-stock', + detected_at: new Date().toISOString(), + days_remaining: 5, + supplier_name: 'Fornecedor C', + category_name: 'Categoria Gamma' + } + ]) + }); + }); + }); + + test('Card Edge Cases - Visual Consistency', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + // Take screenshot of the variations + await expect(grid).toHaveScreenshot('novelty-card-variations.png', { + maxDiffPixelRatio: 0.05 + }); + + // Check specific heights to ensure alignment + const cards = page.locator('div[role="listitem"]'); + const count = await cards.count(); + expect(count).toBe(3); + + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + const h3 = card.locator('h3'); + const priceContainer = card.locator('.min-h-\\[3\\.25rem\\]'); + + const h3Box = await h3.boundingBox(); + const priceBox = await priceContainer.boundingBox(); + + if (h3Box) { + // Should have a consistent minimum height even with different content + expect(h3Box.height).toBeGreaterThanOrEqual(40); + } + if (priceBox) { + expect(priceBox.height).toBeGreaterThanOrEqual(52); + } + } + }); +}); diff --git a/e2e/routes/app/novelty-grid-visual.spec.ts b/e2e/routes/app/novelty-grid-visual.spec.ts new file mode 100644 index 000000000..984d42a03 --- /dev/null +++ b/e2e/routes/app/novelty-grid-visual.spec.ts @@ -0,0 +1,247 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const viewports = [ + { width: 360, height: 800, name: 'mobile-360' }, + { width: 768, height: 1024, name: 'tablet-768' }, + { width: 1024, height: 768, name: 'tablet-1024' }, + { width: 1440, height: 900, name: 'desktop-1440' }, +]; + +test.describe('Novelty Grid Advanced Visual & A11y @mobile', () => { + test.beforeEach(async ({ context }) => { + await context.addInitScript(() => { + const defaultFlags = { + 'mfa': 'false', + 'ai_recommendations': 'true', + 'presentation_mode': 'true', + 'voice_commands': 'true', + 'magic_up': 'true', + 'e2e_tests': 'true', + 'advanced_analytics': 'true', + 'custom_kits_v2': 'false' + }; + + Object.entries(defaultFlags).forEach(([flag, value]) => { + localStorage.setItem(`ff_${flag}`, value); + }); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then(registrations => { + for (const registration of registrations) { + registration.unregister(); + } + }); + } + }); + }); + + for (const viewport of viewports) { + test.describe(`Viewport: ${viewport.name}`, () => { + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + test('Header Immediate Rendering & Visual', async ({ page }) => { + await page.goto('/novidades', { waitUntil: 'domcontentloaded' }); + + const header = page.locator('div.flex.flex-col.gap-4').first(); + const title = header.locator('[data-testid="page-title-novidades"]'); + const desc = header.locator('[data-testid="novelty-description"]'); + + await expect(title).toBeVisible(); + await expect(title).toHaveText('Novidades'); + await expect(desc).toHaveText('Produtos recém-chegados ao catálogo nos últimos 30 dias'); + + await expect(header).toHaveScreenshot(`novelty-header-only-${viewport.name}.png`); + }); + + test('Grid Visual Regression & Scroll', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + await page.waitForTimeout(1000); + + await expect(grid).toHaveScreenshot(`novelty-grid-initial-${viewport.name}.png`, { + maxDiffPixelRatio: 0.02, + }); + + await grid.evaluate(el => el.scrollTop = 1000); + await page.waitForTimeout(800); + + await expect(grid).toHaveScreenshot(`novelty-grid-scrolled-${viewport.name}.png`, { + maxDiffPixelRatio: 0.02, + }); + }); + + test('Accessibility Scan', async ({ page }) => { + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + const results = await new AxeBuilder({ page }) + .include('div[role="list"]') + .analyze(); + + if (results.violations.length > 0) { + console.error(`A11y Violations for viewport ${viewport.name}:`); + results.violations.forEach(v => { + console.error(`- [${v.id}] ${v.help} (${v.impact})`); + console.error(` URL: ${v.helpUrl}`); + console.error(` Nodes: ${v.nodes.length}`); + }); + } + + expect(results.violations, `A11y violations found in ${viewport.name}: ${results.violations.map(v => v.id).join(', ')}`).toEqual([]); + }); + + test('Browser Preferences - Accessibility Consistency', async ({ page }) => { + // Simulate high contrast / large font via CSS injection + await page.addStyleTag({ + content: ` + html { font-size: 20px !important; } + * { transition: none !important; animation: none !important; } + ` + }); + + await page.goto('/novidades'); + const grid = page.locator('div[role="list"]'); + await grid.waitFor({ state: 'visible' }); + + await expect(grid).toHaveScreenshot(`novelty-grid-a11y-prefs-${viewport.name}.png`); + }); + + test('Keyboard Navigation', async ({ page }) => { + await page.goto('/novidades'); + await page.keyboard.press('Tab'); + + const activeElement = await page.evaluate(() => document.activeElement?.tagName); + expect(activeElement).toBeDefined(); + + await page.keyboard.press('Tab'); + await expect(page).toHaveScreenshot(`novelty-keyboard-focus-${viewport.name}.png`); + }); + }); + } + + test('Skeleton State & Layout Stability', async ({ page }) => { + // Intercept with delay to see skeleton + await page.route('**/api/external-db', async route => { + if (route.request().postDataJSON()?.operation === 'select') { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + await route.continue(); + }); + + await page.goto('/novidades'); + const skeleton = page.locator('.animate-spin').first(); + await expect(skeleton).toBeVisible(); + + // Capture skeleton grid + const grid = page.locator('div.grid').filter({ has: page.locator('.animate-pulse') }).first(); + await expect(grid).toHaveScreenshot('novelty-skeleton-state.png'); + + // Wait for data and check stability + await page.waitForSelector('div[role="list"]'); + const realGrid = page.locator('div[role="list"]'); + await expect(realGrid).toBeVisible(); + await expect(realGrid).toHaveScreenshot('novelty-data-loaded-stability.png'); + }); + + test('Pagination & Alignment Check', async ({ page }) => { + // Mock enough products for multiple pages + await page.route('**/api/external-db', async route => { + const body = route.request().postDataJSON(); + if (body?.operation === 'select' && body?.table === 'products') { + const mockProducts = Array.from({ length: 45 }, (_, i) => ({ + id: `page-mock-${i}`, + name: `Product ${i} ${i % 3 === 0 ? 'with a very very very long name to test wrapping and alignment consistency across the grid' : ''}`, + sku: `SKU-${i}`, + primary_image_url: null, + sale_price: i % 5 === 0 ? null : 100 + i, + category_id: 'cat-1', + supplier_id: 'sup-1', + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + })); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ records: mockProducts, count: 45 }) + }); + } else { + await route.continue(); + } + }); + + await page.goto('/novidades'); + const paginator = page.locator('nav[aria-label="pagination"]'); + await paginator.waitFor(); + + // Check first page alignment + const firstPageItems = page.locator('div[role="listitem"]'); + await expect(firstPageItems).toHaveCount(20); + await expect(page).toHaveScreenshot('novelty-pagination-page-1.png'); + + // Click next + await page.click('a[aria-label="Go to next page"]'); + await page.waitForTimeout(500); + await expect(page.locator('div[role="listitem"]')).toHaveCount(20); + await expect(page).toHaveScreenshot('novelty-pagination-page-2.png'); + + // Click last page (3) + await page.click('a:text("3")'); + await page.waitForTimeout(500); + await expect(page.locator('div[role="listitem"]')).toHaveCount(5); + await expect(page).toHaveScreenshot('novelty-pagination-last-page.png'); + }); + + test('Card Variations: Long Title & Consultation Price', async ({ page }) => { + await page.route('**/api/external-db', async route => { + const body = route.request().postDataJSON(); + if (body?.operation === 'select' && body?.table === 'products') { + const mockProducts = [ + { + id: 'var-1', + name: 'Short Title', + sku: 'SKU-1', + sale_price: 100, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + }, + { + id: 'var-2', + name: 'This is a very long product name that should definitely wrap to multiple lines and potentially push the layout down if not handled correctly by min-height constraints', + sku: 'SKU-2', + sale_price: 200, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + }, + { + id: 'var-3', + name: 'Consultation Price Item', + sku: 'SKU-3', + sale_price: null, + created_at: new Date().toISOString(), + stock_quantity: 100, + min_quantity: 10 + } + ]; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ records: mockProducts }) + }); + } else { + await route.continue(); + } + }); + + await page.goto('/novidades'); + const cards = page.locator('div[role="listitem"]'); + await expect(cards).toHaveCount(3); + + await expect(page.locator('div[role="list"]')).toHaveScreenshot('novelty-card-variations.png'); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index fb5aa36e5..3566ad4b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: RETRIES, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, // CI dobra o per-test timeout para absorver teardown lento do browser context // em rotas com ProtectedRoute (effects pendentes fazem context.close atrasar). // Local mantém 45s para detectar regressões cedo. diff --git a/src/components/loading/ModernSkeletons.tsx b/src/components/loading/ModernSkeletons.tsx index abb99fe59..db47c07cc 100644 --- a/src/components/loading/ModernSkeletons.tsx +++ b/src/components/loading/ModernSkeletons.tsx @@ -90,19 +90,25 @@ export function ProductCardSkeleton({ - {/* Title - Fixed min-height to prevent layout shift */} -
- Produtos recém-chegados ao catálogo nos últimos 30 dias -
++ Produtos recém-chegados ao catálogo nos últimos 30 dias +
+