/** * [TEC-3078] Visual/density regression — breakpoint 1920/1440/1280/1024/768px * [TEC-3079] Console errors = 0 và network 4xx/5xx = 0 * [TEC-3080] Interaction: sort/filter bảng, ticker, chart render * * Goodgo Platform AI — sàn giao dịch refactor QA * Chạy E2E với backend thật. Không stub/mock. * * npx playwright test --project=web e2e/web/trading-floor-regression.spec.ts */ import { test, expect } from '@playwright/test'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const BREAKPOINTS = [ { name: '1920p', width: 1920, height: 1080 }, { name: '1440p', width: 1440, height: 900 }, { name: '1280p', width: 1280, height: 800 }, { name: '1024p', width: 1024, height: 768 }, { name: '768p', width: 768, height: 1024 }, ] as const; function attachNetworkMonitor(page: import('@playwright/test').Page) { const consoleErrors: string[] = []; const networkErrors: { url: string; status: number }[] = []; page.on('console', (msg) => { if (msg.type() === 'error') { const text = msg.text(); if ( text.includes('mapbox') || text.includes('NEXT_PUBLIC_MAPBOX') || text.includes('Failed to load resource') || text.includes('net::ERR') ) return; consoleErrors.push(text); } }); page.on('response', (res) => { const status = res.status(); const url = res.url(); if (status >= 400 && (url.includes('/api/v1') || url.includes('localhost'))) { networkErrors.push({ url, status }); } }); return { consoleErrors, networkErrors }; } // --------------------------------------------------------------------------- // [TEC-3078] Breakpoint regression // --------------------------------------------------------------------------- test.describe('Breakpoint regression — sàn giao dịch', () => { for (const bp of BREAKPOINTS) { test(`/ home dashboard — ${bp.name} (${bp.width}×${bp.height})`, async ({ page }) => { await page.setViewportSize({ width: bp.width, height: bp.height }); await page.goto('/'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Không crash await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); // Main content vẫn visible ở mọi breakpoint await expect(page.locator('main').or(page.locator('#__next'))).toBeVisible(); // Không có horizontal overflow (scrollWidth > clientWidth = layout break) const hasHorizontalOverflow = await page.evaluate(() => { return document.documentElement.scrollWidth > document.documentElement.clientWidth + 2; }); expect( hasHorizontalOverflow, `Horizontal overflow tại ${bp.name} — kiểm tra CSS layout`, ).toBeFalsy(); }); test(`/listings board — ${bp.name} (${bp.width}×${bp.height})`, async ({ page }) => { await page.setViewportSize({ width: bp.width, height: bp.height }); await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); // Bảng hoặc card listings phải visible const listingEl = page .locator('table') .or(page.locator('[data-testid="data-table"]')) .or(page.locator('[class*="listing"]')) .or(page.getByRole('grid')); await expect(listingEl.first()).toBeVisible({ timeout: 15_000 }); }); } }); // --------------------------------------------------------------------------- // [TEC-3079] Console errors = 0 / Network 4xx/5xx = 0 // --------------------------------------------------------------------------- test.describe('Zero console errors & zero 4xx/5xx — các route chính', () => { const ROUTES = ['/', '/listings', '/login'] as const; for (const route of ROUTES) { test(`${route} — không có console error và không có API 5xx`, async ({ page }) => { const { consoleErrors, networkErrors } = attachNetworkMonitor(page); await page.goto(route); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); expect( consoleErrors, `Console errors tại ${route}: ${consoleErrors.join(' | ')}`, ).toHaveLength(0); const fiveXX = networkErrors.filter((e) => e.status >= 500); expect( fiveXX, `Network 5xx tại ${route}: ${fiveXX.map((e) => `${e.status} ${e.url}`).join(' | ')}`, ).toHaveLength(0); }); } test('/listings — không có API 4xx (unauthorized ngoại lệ cho /admin)', async ({ page }) => { const { networkErrors } = attachNetworkMonitor(page); await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); const unexpectedFourXX = networkErrors.filter( (e) => e.status >= 400 && e.status < 500 && // Bỏ qua 401 cho các endpoint yêu cầu auth (bình thường khi chưa đăng nhập) e.status !== 401 && // Bỏ qua preflight OPTIONS !e.url.includes('OPTIONS'), ); expect( unexpectedFourXX, `Unexpected 4xx tại /listings: ${unexpectedFourXX.map((e) => `${e.status} ${e.url}`).join(' | ')}`, ).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // [TEC-3080] Interaction: sort/filter bảng, DensityToggle, preview panel // --------------------------------------------------------------------------- test.describe('Interaction — Listings board (sort, filter, density, preview)', () => { test('sort cột giá hoạt động (click header)', async ({ page }) => { await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Tìm header cột giá trong DataTable const priceHeader = page .getByRole('columnheader', { name: /giá|price/i }) .or(page.locator('th').filter({ hasText: /giá|price/i })); if (await priceHeader.first().isVisible({ timeout: 5_000 }).catch(() => false)) { await priceHeader.first().click(); // Sau click không crash await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); // Table vẫn hiện await expect( page.locator('table').or(page.getByRole('grid')).first(), ).toBeVisible(); } }); test('DensityToggle compact/comfortable chuyển đổi không crash', async ({ page }) => { await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); const toggle = page .locator('[data-testid="density-toggle"]') .or(page.getByRole('button', { name: /compact|comfortable|density/i })) .or(page.locator('button[class*="density"]')); if (await toggle.first().isVisible({ timeout: 5_000 }).catch(() => false)) { // Click 2 lần (compact → comfortable → compact) await toggle.first().click(); await page.waitForTimeout(300); await toggle.first().click(); await page.waitForTimeout(300); await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); } }); test('click row điều hướng đến listing detail (hoặc mở PreviewPanel)', async ({ page }) => { await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Tìm row đầu tiên trong table const firstRow = page .locator('tbody tr') .or(page.locator('[role="row"]').nth(1)) // nth(0) là header .first(); if (await firstRow.isVisible({ timeout: 10_000 }).catch(() => false)) { // Design hiện tại: click row → router.push('/listings/{id}') → navigate sang detail page // Có thể trong tương lai sẽ mở PreviewPanel thay thế await Promise.race([ firstRow.click({ timeout: 5_000 }), page.waitForURL(/\/listings\/[\w-]+/, { timeout: 10_000 }).catch(() => {}), ]).catch(() => {}); // Sau khi click: hoặc đang ở detail page, hoặc panel mở, hoặc vẫn ở /listings // Không quan trọng outcome — chỉ cần không crash 500 await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {}); // Kiểm tra side panel nếu có (design tương lai) const panel = page .locator('[data-testid="listing-preview-panel"]') .or(page.locator('[class*="preview-panel"]')) .or(page.locator('[class*="PreviewPanel"]')) .or(page.locator('[role="dialog"]')); const panelVisible = await panel.first().isVisible({ timeout: 2_000 }).catch(() => false); if (panelVisible) { await expect(panel.first()).toBeVisible(); } // Không crash dù navigate hay panel await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); } }); }); // --------------------------------------------------------------------------- // [TEC-3080] Interaction — Home dashboard ticker // --------------------------------------------------------------------------- test.describe('Interaction — Home dashboard ticker & market data', () => { test('ticker hoặc market data section render đúng', async ({ page }) => { const { consoleErrors } = attachNetworkMonitor(page); await page.goto('/'); // Đợi ticker animation hoặc data load await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); await page.waitForTimeout(1_000); // cho animation chạy 1 giây // Ticker hoặc market section phải visible const ticker = page .locator('[data-testid="ticker"]') .or(page.locator('[class*="ticker"]')) .or(page.locator('[class*="Ticker"]')) .or(page.getByText(/giá tb|avg|m²|tỷ/i)); if (await ticker.first().isVisible({ timeout: 8_000 }).catch(() => false)) { await expect(ticker.first()).toBeVisible(); } // Không có error boundary dù ticker chưa render await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); expect( consoleErrors, `Console errors tại /: ${consoleErrors.join(' | ')}`, ).toHaveLength(0); }); test('navigation chính hoạt động từ home', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Click vào link Listings từ nav const listingsLink = page .getByRole('link', { name: /tin đăng|listings|bất động sản/i }) .first(); if (await listingsLink.isVisible({ timeout: 5_000 }).catch(() => false)) { await listingsLink.click(); await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }); await expect(page).toHaveURL(/\/listings/); } }); }); // --------------------------------------------------------------------------- // [TEC-3080] Interaction — Listing detail inquiry modal // --------------------------------------------------------------------------- test.describe('Interaction — Listing detail (inquiry modal, breadcrumb)', () => { test('breadcrumb và back navigation hoạt động', async ({ page }) => { // Đi từ /listings → listing detail → back await page.goto('/listings'); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Click vào listing đầu tiên const listingLink = page .locator('a[href*="/listings/"]') .filter({ hasNot: page.locator('a[href="/listings"]') }) .first(); if (await listingLink.isVisible({ timeout: 10_000 }).catch(() => false)) { await listingLink.click(); await page.waitForLoadState('networkidle', { timeout: 20_000 }).catch(() => {}); // Breadcrumb hoặc back button hiển thị const breadcrumb = page .locator('[aria-label="breadcrumb"]') .or(page.locator('nav[class*="breadcrumb"]')) .or(page.getByRole('link', { name: /trang chủ|home|quay lại/i })); if (await breadcrumb.first().isVisible({ timeout: 3_000 }).catch(() => false)) { await expect(breadcrumb.first()).toBeVisible(); } // Không có 500 await expect( page.getByRole('heading', { name: /500|server error/i }), ).not.toBeVisible(); } }); });