Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/visual-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,20 +32,19 @@ 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

- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v5
with:
name: playwright-report
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
retention-days: 30

Expand Down
90 changes: 90 additions & 0 deletions e2e/routes/app/novelty-card-variations.spec.ts
Original file line number Diff line number Diff line change
@@ -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([
Comment on lines +6 to +10
{
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);
}
}
});
});
247 changes: 247 additions & 0 deletions e2e/routes/app/novelty-grid-visual.spec.ts
Original file line number Diff line number Diff line change
@@ -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"]');
Comment on lines +46 to +48

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();
Comment on lines +127 to +131
});

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 => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mock the endpoint the novelties page actually calls

These pagination/variation tests depend on mocked novelty data, but the novelties hook reads the products table through invokeExternalDb/REST-native Supabase calls, not an **/api/external-db endpoint. In CI or local runs this route therefore will not intercept the page's data fetch, so the tests exercise whatever real/fallback data is returned and assertions such as waiting for a paginator with 45 mocked products will fail or become non-deterministic.

Useful? React with 👍 / 👎.

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');
});
});
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading