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

Choose a reason for hiding this comment

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

P1 Badge Mock the endpoint the novelties page actually calls

The new novelty tests install fixtures on routes such as this functions/v1/novelties URL (and **/api/external-db in the companion visual spec), but /novidades gets its data through useNoveltiesWithDetailsinvokeExternalDb, which queries Supabase REST for products and only falls back to external-db-bridge; no request is made to these mocked endpoints. In CI the assertions for exactly 3 or 45 mocked cards therefore run against real/fallback data instead of the fixture and will fail or time out depending on the environment.

Useful? React with 👍 / 👎.

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'
},
Comment on lines +11 to +24
{
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'
},
Comment on lines +25 to +38
{
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'
}
Comment on lines +39 to +52
])
});
});
});

test('Card Edge Cases - Visual Consistency', async ({ page }) => {
await page.goto('/novidades');
const grid = page.locator('div[role="list"]');
await grid.waitFor({ state: 'visible' });
Comment on lines +60 to +61

// 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();
Comment on lines +69 to +70
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);
Comment on lines +59 to +61

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();

Comment on lines +80 to +83
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 +132

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

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);
Comment on lines +186 to +193
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') {
Comment on lines +199 to +201
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 })
});
Comment on lines +231 to +235
} 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