fix(regression): resolve dogfood findings across marketing, auth, e2e and docs
- Fix list-to-detail navigation on product/service/solution/case pages - Fix soft 404 on service detail by removing (marketing)/loading.tsx and using force-dynamic - Fix contact form submission feedback and news placeholder image handling - Unify SSR/client authentication state in auth.ts - Add "新闻动态" to main navigation - Fix Playwright storageState path and Firefox footer link flakiness - Add E2E coverage for nav dropdown, cases filter and auth token parsing - Update visual regression baselines (desktop/tablet/mobile, chromium/webkit/firefox) - Update README, lessons-learned and add REGRESSION_REPORT_2026-07-27.md - Ignore .lighthouseci/ and heading-hierarchy-report.json
@@ -0,0 +1,22 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('案例页行业筛选按钮可以正确过滤列表', async ({ page }) => {
|
||||
await page.goto('/cases', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 默认显示全部 6 个案例
|
||||
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
|
||||
|
||||
// 点击“制造业”筛选
|
||||
await page.getByRole('radio', { name: '制造业' }).click();
|
||||
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
|
||||
await expect(page.locator('text=大型制造企业 ERP 升级与数字化转型')).toBeVisible();
|
||||
|
||||
// 点击“贸易零售”筛选
|
||||
await page.getByRole('radio', { name: '贸易零售' }).click();
|
||||
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
|
||||
await expect(page.locator('text=连锁零售全渠道数字化升级')).toBeVisible();
|
||||
|
||||
// 切回全部
|
||||
await page.getByRole('radio', { name: '全部行业' }).click();
|
||||
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// 加载项目根目录的 .env.local,使 E2E 测试与 dev/preview 服务器使用相同的 CMS_REVALIDATE_SECRET
|
||||
// Playwright 配置已负责设置 process.env,此处仅做兜底加载,不再 override 以避免覆盖 webServer 环境变量
|
||||
dotenvConfig({ path: path.resolve(__dirname, '../.env.local') });
|
||||
|
||||
// CMS 工作流依赖共享数据库与角色权限,浏览器差异可忽略,仅在 chromium 项目运行以避免跨 project 数据竞争
|
||||
test.skip(({ browserName }) => browserName !== 'chromium', 'CMS workflow test runs only in chromium');
|
||||
|
||||
/**
|
||||
* CMS 内容发布工作流 E2E 测试
|
||||
@@ -13,8 +22,60 @@ const REVIEWER_CREDENTIALS = { username: 'e2e_reviewer', password: 'reviewer123'
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
||||
const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
||||
|
||||
// 跟踪当前测试创建的内容条目 ID,afterEach 只清理当前测试的数据,避免并行/跨 project 误删
|
||||
const createdItemIds: string[] = [];
|
||||
|
||||
test.setTimeout(120000);
|
||||
|
||||
async function deleteItemsByIds(request: APIRequestContext, token: string, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
ids.map((id) =>
|
||||
request.delete(`${BASE_URL}/api/admin/items?id=${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupStaleE2ENews(request: APIRequestContext, token: string): Promise<void> {
|
||||
const response = await request.get(`${BASE_URL}/api/admin/items`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { modelCode: 'news', pageSize: '1000' },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
console.warn('Failed to fetch news items for cleanup:', await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
const items = (body.items || body.data || []) as Array<{ id: string; title: string }>;
|
||||
const staleItems = items.filter((item) => item.title?.startsWith('E2E 测试新闻'));
|
||||
|
||||
await deleteItemsByIds(
|
||||
request,
|
||||
token,
|
||||
staleItems.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// 清理历史残留 E2E 测试数据(仅在 chromium 项目执行,不会误删其他 project 数据)
|
||||
const token = await loginAdmin(request);
|
||||
await cleanupStaleE2ENews(request, token);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
const token = await loginAdmin(request);
|
||||
// 优先按 ID 清理当前测试创建的条目,避免误删其他并行测试的数据
|
||||
const ids = createdItemIds.splice(0);
|
||||
await deleteItemsByIds(request, token, ids);
|
||||
// 再兜底清理历史残留
|
||||
await cleanupStaleE2ENews(request, token);
|
||||
});
|
||||
|
||||
async function login(
|
||||
request: APIRequestContext,
|
||||
credentials: { username: string; password: string }
|
||||
@@ -83,6 +144,7 @@ async function createNewsDraft(
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('draft');
|
||||
|
||||
createdItemIds.push(body.id);
|
||||
return { id: body.id, slug, title };
|
||||
}
|
||||
|
||||
@@ -108,7 +170,7 @@ async function approveItem(
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
itemId: string
|
||||
): Promise<void> {
|
||||
): Promise<{ status: string; slug: string }> {
|
||||
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
|
||||
data: { action: 'approve' },
|
||||
headers: {
|
||||
@@ -120,6 +182,7 @@ async function approveItem(
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('published');
|
||||
return body;
|
||||
}
|
||||
|
||||
async function revalidateNews(request: APIRequestContext, slug: string): Promise<void> {
|
||||
@@ -133,8 +196,11 @@ async function revalidateNews(request: APIRequestContext, slug: string): Promise
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
console.log('[DEBUG] revalidate status=%s body=%s', response.status(), responseBody);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
const body = JSON.parse(responseBody);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.revalidatedPaths).toContain('/news');
|
||||
expect(body.data.revalidatedPaths).toContain(`/news/${slug}`);
|
||||
@@ -162,18 +228,28 @@ async function ensureRolePermissions(
|
||||
|
||||
async function verifyNewsVisibleOnFrontend(
|
||||
page: import('@playwright/test').Page,
|
||||
request: APIRequestContext,
|
||||
slug: string,
|
||||
title: string,
|
||||
suffix: string
|
||||
): Promise<void> {
|
||||
// 先用 API context 拉取本地页面,确认服务端渲染正常
|
||||
const apiResponse = await request.get(`${BASE_URL}/news/${slug}`);
|
||||
const apiHtml = await apiResponse.text();
|
||||
console.log('[DEBUG] api status=%s title=%s', apiResponse.status(), apiHtml.match(/<title>([^<]+)<\/title>/)?.[1]);
|
||||
|
||||
// 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动)
|
||||
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
const currentUrl = page.url();
|
||||
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
||||
console.log('[DEBUG] page.url=%s h1=%s expected title=%s', currentUrl, h1Text, title);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
||||
return h1Text.includes(title);
|
||||
const text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
|
||||
return text.includes(title);
|
||||
},
|
||||
{ timeout: 20000, intervals: [1000, 2000, 2000] }
|
||||
)
|
||||
@@ -183,15 +259,21 @@ async function verifyNewsVisibleOnFrontend(
|
||||
}
|
||||
|
||||
test.describe('CMS 内容发布工作流', () => {
|
||||
// 同一 worker 内串行执行,避免共享 DB/角色权限在并行时相互影响
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const token = await loginAdmin(request);
|
||||
const { id, slug, title } = await createNewsDraft(request, token, suffix);
|
||||
console.log('[DEBUG] created item id=%s slug=%s title=%s', id, slug, title);
|
||||
await submitForReview(request, token, id);
|
||||
await approveItem(request, token, id);
|
||||
const afterApprove = await approveItem(request, token, id);
|
||||
console.log('[DEBUG] after approve status=%s slug=%s', afterApprove.status, afterApprove.slug);
|
||||
await revalidateNews(request, slug);
|
||||
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
|
||||
console.log('[DEBUG] revalidate done, navigating to /news/%s', slug);
|
||||
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
|
||||
});
|
||||
|
||||
test('非法状态流转会被拦截', async ({ request }) => {
|
||||
@@ -255,6 +337,6 @@ test.describe('CMS 内容发布工作流', () => {
|
||||
|
||||
// 5. 刷新 ISR 缓存并验证前台可见
|
||||
await revalidateNews(request, slug);
|
||||
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
|
||||
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('主导航产品下拉菜单可在 hover 时展开', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const productsButton = page.locator('nav[aria-label="主导航"] button:has-text("产品")');
|
||||
await expect(productsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await productsButton.hover();
|
||||
await expect(productsButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('text=企业套装').first()).toBeVisible();
|
||||
await expect(page.locator('text=ERP 管理系统').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('主导航解决方案下拉菜单可在 hover 时展开', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const solutionsButton = page.locator('nav[aria-label="主导航"] button:has-text("解决方案")');
|
||||
await expect(solutionsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await solutionsButton.hover();
|
||||
await expect(solutionsButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(page.locator('text=行业解决方案').first()).toBeVisible();
|
||||
await expect(page.locator('text=制造业').first()).toBeVisible();
|
||||
});
|
||||
@@ -647,27 +647,46 @@ test.describe('Footer - 全局底部区域', () => {
|
||||
});
|
||||
|
||||
test('Footer 导航链接可点击', async ({ page }) => {
|
||||
const footer = page.locator('footer, [data-testid="footer"]').first();
|
||||
|
||||
// 检查隐私政策和服务条款链接
|
||||
const privacyLink = footer.locator('a:has-text("隐私政策")');
|
||||
if (await privacyLink.count() > 0 && await privacyLink.isVisible()) {
|
||||
await privacyLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/privacy');
|
||||
}
|
||||
|
||||
// 返回首页检查服务条款
|
||||
// 隐私政策链接:独立测试,避免 Firefox 中连续全页导航的竞态问题
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const termsLink = footer.locator('a:has-text("服务条款")');
|
||||
if (await termsLink.count() > 0 && await termsLink.isVisible()) {
|
||||
await termsLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/terms');
|
||||
}
|
||||
const privacyClicked = await page.evaluate(() => {
|
||||
const link = document.querySelector('footer a, [data-testid="footer"] a') as HTMLAnchorElement | null;
|
||||
if (!link) return false;
|
||||
const privacy = Array.from(document.querySelectorAll('footer a, [data-testid="footer"] a'))
|
||||
.find((el) => el.textContent?.includes('隐私政策')) as HTMLAnchorElement | undefined;
|
||||
if (privacy) {
|
||||
privacy.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
privacy.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(privacyClicked).toBe(true);
|
||||
await page.waitForURL(/\/privacy/, { timeout: 15000 });
|
||||
expect(page.url()).toContain('/privacy');
|
||||
});
|
||||
|
||||
test('Footer 服务条款链接可点击', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const termsClicked = await page.evaluate(() => {
|
||||
const terms = Array.from(document.querySelectorAll('footer a, [data-testid="footer"] a'))
|
||||
.find((el) => el.textContent?.includes('服务条款')) as HTMLAnchorElement | undefined;
|
||||
if (terms) {
|
||||
terms.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
terms.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(termsClicked).toBe(true);
|
||||
await page.waitForURL(/\/terms/, { timeout: 15000 });
|
||||
expect(page.url()).toContain('/terms');
|
||||
});
|
||||
|
||||
test('Footer 包含联系方式(邮箱)', async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// 为 CMS 工作流 E2E 测试提供默认 revalidate secret
|
||||
// 加载项目根目录的 .env.local,使 Playwright webServer 与测试进程使用相同的 CMS_REVALIDATE_SECRET
|
||||
// 若 .env.local 不存在则回退到默认测试 secret
|
||||
dotenvConfig({ path: path.resolve(__dirname, '../.env.local') });
|
||||
process.env.CMS_REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -17,7 +21,8 @@ export default defineConfig({
|
||||
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
||||
storageState: './storageState.json',
|
||||
// 路径以配置文件所在目录为基准,兼容 `cd e2e` 与从项目根目录直接调用两种执行方式
|
||||
storageState: path.resolve(__dirname, 'storageState.json'),
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
|
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 966 B After Width: | Height: | Size: 914 B |
|
Before Width: | Height: | Size: 970 B After Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 959 B After Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 1000 KiB After Width: | Height: | Size: 932 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 692 KiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 505 KiB |
|
Before Width: | Height: | Size: 593 KiB After Width: | Height: | Size: 595 KiB |
|
Before Width: | Height: | Size: 582 KiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 583 KiB After Width: | Height: | Size: 585 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 591 KiB After Width: | Height: | Size: 593 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 796 KiB After Width: | Height: | Size: 801 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 776 KiB After Width: | Height: | Size: 777 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 677 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 991 KiB |
|
Before Width: | Height: | Size: 1015 KiB After Width: | Height: | Size: 1019 KiB |
|
Before Width: | Height: | Size: 700 KiB After Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 789 KiB After Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 831 KiB After Width: | Height: | Size: 832 KiB |
|
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 804 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 816 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 916 KiB After Width: | Height: | Size: 919 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -14,42 +14,35 @@ test.describe('网站全面测试验收', () => {
|
||||
test('公司Logo可见且不被覆盖', async ({ page }) => {
|
||||
const logo = page.locator('header img[alt*="睿新致远"], [data-testid="logo"]');
|
||||
const logoCount = await logo.count();
|
||||
|
||||
|
||||
async function assertLogoBoundingBox(logoLocator: ReturnType<typeof page.locator>) {
|
||||
await expect(logoLocator).toBeVisible({ timeout: 10000 });
|
||||
// 等待 layout 完成,避免 WebKit 中 boundingBox() 首次返回 null
|
||||
const logoBox = await expect.poll(async () => logoLocator.boundingBox(), {
|
||||
timeout: 5000,
|
||||
intervals: [100, 200, 200],
|
||||
}).not.toBeNull();
|
||||
|
||||
const header = page.locator('header');
|
||||
const headerBox = await expect.poll(async () => header.boundingBox(), {
|
||||
timeout: 5000,
|
||||
intervals: [100, 200, 200],
|
||||
}).not.toBeNull();
|
||||
|
||||
if (logoBox && headerBox) {
|
||||
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
||||
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
||||
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
||||
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (logoCount === 0) {
|
||||
const allImages = page.locator('header img');
|
||||
expect(await allImages.count()).toBeGreaterThan(0);
|
||||
const firstLogo = allImages.first();
|
||||
await expect(firstLogo).toBeVisible();
|
||||
|
||||
const logoBox = await firstLogo.boundingBox();
|
||||
expect(logoBox).not.toBeNull();
|
||||
|
||||
const header = page.locator('header');
|
||||
const headerBox = await header.boundingBox();
|
||||
expect(headerBox).not.toBeNull();
|
||||
|
||||
if (logoBox && headerBox) {
|
||||
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
||||
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
||||
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
||||
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
||||
}
|
||||
await assertLogoBoundingBox(allImages.first());
|
||||
} else {
|
||||
await expect(logo.first()).toBeVisible();
|
||||
|
||||
const logoBox = await logo.first().boundingBox();
|
||||
expect(logoBox).not.toBeNull();
|
||||
|
||||
const header = page.locator('header');
|
||||
const headerBox = await header.boundingBox();
|
||||
expect(headerBox).not.toBeNull();
|
||||
|
||||
if (logoBox && headerBox) {
|
||||
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
|
||||
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
|
||||
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
|
||||
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
|
||||
}
|
||||
await assertLogoBoundingBox(logo.first());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||