test(e2e,unit): add navigation edge cases and component coverage
- Add missing-paths E2E spec for desktop/mobile navigation and contrast - Add local Playwright config for dev server testing - Expand unit tests for CMS data server, products, contact and UI components - Adjust jest config for new test patterns
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* P5: 边缘情况与缺失路径 E2E 测试
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1. 404 未找到页面
|
||||
* 2. 全局错误边界
|
||||
* 3. 页面加载状态
|
||||
* 4. 无效路由与特殊字符
|
||||
*/
|
||||
|
||||
test.setTimeout(60000);
|
||||
|
||||
// ==================== 1. 404 页面测试 ====================
|
||||
test.describe('404 页面 - 未找到页面', () => {
|
||||
|
||||
test('访问不存在的页面显示 404', async ({ page }) => {
|
||||
await page.goto('/this-page-does-not-exist', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// URL 应保持为请求的路径或重定向到 404 处理
|
||||
expect(page.url()).toContain('this-page-does-not-exist');
|
||||
|
||||
// 检查 404 页面关键元素
|
||||
const heading = page.locator('h1').first();
|
||||
await expect(heading).toBeVisible();
|
||||
const headingText = await heading.textContent();
|
||||
expect(headingText).toMatch(/404|页面未找到/);
|
||||
});
|
||||
|
||||
test('404 页面包含返回首页按钮', async ({ page }) => {
|
||||
await page.goto('/non-existent-page', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const homeButton = page.locator('a[href="/"]').first();
|
||||
await expect(homeButton).toBeVisible();
|
||||
expect(await homeButton.textContent()).toContain('返回首页');
|
||||
});
|
||||
|
||||
test('404 页面推荐链接可点击', async ({ page }) => {
|
||||
await page.goto('/missing-page', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const recommendedLinks = [
|
||||
{ text: '产品', expectedPath: '/products' },
|
||||
{ text: '解决方案', expectedPath: '/solutions' },
|
||||
{ text: '关于我们', expectedPath: '/about' },
|
||||
{ text: '联系我们', expectedPath: '/contact' },
|
||||
];
|
||||
|
||||
for (const { text, expectedPath } of recommendedLinks) {
|
||||
const link = page.locator(`a:has-text("${text}")`).first();
|
||||
if (await link.count() > 0 && await link.isVisible()) {
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toBe(expectedPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('404 页面标题设置正确', async ({ page }) => {
|
||||
await page.goto('/not-found-test', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const title = await page.title();
|
||||
expect(title).toContain('页面未找到');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 2. 错误边界测试 ====================
|
||||
test.describe('错误边界 - 应用级错误处理', () => {
|
||||
|
||||
test('错误页面包含重试按钮', async ({ page }) => {
|
||||
// 通过直接访问错误页面组件路径模拟错误状态
|
||||
await page.goto('/error-test-trigger', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 检查是否有返回首页链接(错误边界通用元素)
|
||||
const homeLink = page.locator('a[href="/"]').first();
|
||||
if (await homeLink.count() > 0) {
|
||||
await expect(homeLink).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('错误页面包含返回首页链接', async ({ page }) => {
|
||||
await page.goto('/error-boundary-test', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const homeLink = page.locator('a[href="/"]').first();
|
||||
if (await homeLink.count() > 0) {
|
||||
const text = await homeLink.textContent();
|
||||
expect(text).toContain('返回首页');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 3. 加载状态测试 ====================
|
||||
test.describe('加载状态 - 页面过渡', () => {
|
||||
|
||||
test('页面加载时显示主要内容', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
// 页面加载后 header 应可见
|
||||
await expect(page.locator('header')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// main 内容应存在
|
||||
const mainContent = page.locator('main, [role="main"]').first();
|
||||
await expect(mainContent).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('页面导航不应完全空白', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 点击到产品页面
|
||||
const productLink = page.locator('a[href="/products"]').first();
|
||||
if (await productLink.count() > 0 && await productLink.isVisible()) {
|
||||
await productLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 导航过程中页面仍应包含某些元素
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
expect(bodyText!.length).toBeGreaterThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
test('刷新页面后内容仍然加载', async ({ page }) => {
|
||||
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 刷新页面
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 页面应仍然加载成功
|
||||
const mainContent = page.locator('main, [role="main"]').first();
|
||||
await expect(mainContent).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 4. 无效路由与特殊字符测试 ====================
|
||||
test.describe('无效路由 - 边界 URL 处理', () => {
|
||||
|
||||
test('带特殊字符的路由不崩溃', async ({ page }) => {
|
||||
const specialPaths = [
|
||||
'/products/<script>alert(1)</script>',
|
||||
'/about?param=<invalid>',
|
||||
'/contact#%20%20',
|
||||
];
|
||||
|
||||
for (const path of specialPaths) {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 页面不应崩溃,body 应存在
|
||||
const body = page.locator('body').first();
|
||||
await expect(body).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('过长路径不导致页面异常', async ({ page }) => {
|
||||
const longPath = '/a'.repeat(200);
|
||||
await page.goto(longPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 页面应正常渲染(404 页面或正常页面)
|
||||
const body = page.locator('body').first();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
const bodyText = await body.textContent();
|
||||
expect(bodyText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('查询参数不影响页面渲染', async ({ page }) => {
|
||||
await page.goto('/?utm_source=test&utm_medium=email', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 首页应正常渲染
|
||||
const header = page.locator('header').first();
|
||||
await expect(header).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* P6: 缺失路径 E2E 测试补充
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1. 滚动进度条(ScrollProgress)行为
|
||||
* 2. 联系表单完整成功提交流程
|
||||
* 3. 首页 → 产品列表 → 产品详情完整用户路径
|
||||
* 4. 服务详情页滚动进度条
|
||||
*/
|
||||
|
||||
test.setTimeout(60000);
|
||||
|
||||
// ==================== 1. 滚动进度条测试 ====================
|
||||
test.describe('滚动进度条 - 全局组件行为', () => {
|
||||
|
||||
test('页面顶部时进度条缩放为 0', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const progressBar = page.locator('[data-testid="scroll-progress"]');
|
||||
await expect(progressBar).toBeVisible();
|
||||
|
||||
const progressFill = progressBar.locator('> div').first();
|
||||
const scale = await progressFill.evaluate((el) => {
|
||||
const transform = (el as HTMLElement).style.transform;
|
||||
const match = transform.match(/scaleX\(([\d.]+)\)/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
});
|
||||
expect(scale).toBe(0);
|
||||
});
|
||||
|
||||
test('滚动页面时进度条缩放增加', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const progressBar = page.locator('[data-testid="scroll-progress"]');
|
||||
const progressFill = progressBar.locator('> div').first();
|
||||
|
||||
const getScale = () => progressFill.evaluate((el) => {
|
||||
const transform = (el as HTMLElement).style.transform;
|
||||
const match = transform.match(/scaleX\(([\d.]+)\)/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
});
|
||||
|
||||
// 先获取顶部缩放
|
||||
const topScale = await getScale();
|
||||
|
||||
// 滚动到页面底部
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 再次获取缩放
|
||||
const bottomScale = await getScale();
|
||||
|
||||
expect(bottomScale).toBeGreaterThan(topScale);
|
||||
});
|
||||
|
||||
test('进度条颜色使用品牌色变量', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const progressFill = page.locator('[data-testid="scroll-progress"] > div').first();
|
||||
const background = await progressFill.evaluate((el) => window.getComputedStyle(el).backgroundColor);
|
||||
|
||||
// 应存在背景色(具体颜色取决于 CSS 变量解析)
|
||||
expect(background).toBeTruthy();
|
||||
expect(background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 2. 联系表单完整提交流程 ====================
|
||||
test.describe('联系表单 - 完整成功提交', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// 关闭 Cookie 同意横幅,避免覆盖底部表单元素导致点击错位
|
||||
const acceptAllButton = page.locator('button:has-text("接受所有")').first();
|
||||
if (await acceptAllButton.count() > 0 && await acceptAllButton.isVisible()) {
|
||||
await acceptAllButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
async function fillContactForm(page: Page) {
|
||||
await page.locator('[data-testid="name-input"]').fill('测试用户');
|
||||
await page.locator('[data-testid="phone-input"]').fill('13800138000');
|
||||
await page.locator('[data-testid="email-input"]').fill('test@example.com');
|
||||
await page.locator('[data-testid="subject-input"]').fill('E2E 测试咨询');
|
||||
await page.locator('[data-testid="message-input"]').fill('这是一条由 Playwright E2E 测试发送的消息。');
|
||||
}
|
||||
|
||||
async function clickSubmitButton(page: Page) {
|
||||
const submitButton = page.locator('[data-testid="submit-button"]');
|
||||
// 按钮位于页面底部,先滚动到视口并等待布局稳定,避免点击被底部固定栏截获
|
||||
await submitButton.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
// 使用原生 DOM click() 触发提交:Playwright 的 locator.click() 在该按钮上会被内部 flex 内容
|
||||
// 的命中测试干扰,导致事件未正确冒泡到 form 的 React onSubmit 处理器。
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
|
||||
btn?.click();
|
||||
});
|
||||
}
|
||||
|
||||
test('填写有效信息并提交成功', async ({ page }) => {
|
||||
let routeHit = false;
|
||||
// 拦截表单提交 API,返回成功响应
|
||||
await page.route('/api/contact', async (route) => {
|
||||
routeHit = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, message: '发送成功' }),
|
||||
});
|
||||
});
|
||||
|
||||
await fillContactForm(page);
|
||||
await clickSubmitButton(page);
|
||||
|
||||
// 等待成功状态出现
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
console.log('Route hit in success test:', routeHit);
|
||||
|
||||
// 验证成功提示或成功状态存在
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
const hasSuccessIndicator =
|
||||
bodyText!.includes('消息已发送') ||
|
||||
bodyText!.includes('感谢') ||
|
||||
bodyText!.includes('发送成功');
|
||||
expect(hasSuccessIndicator).toBe(true);
|
||||
});
|
||||
|
||||
test('提交时显示加载状态', async ({ page }) => {
|
||||
let routeHit = false;
|
||||
// 延迟 API 响应以观察加载状态
|
||||
await page.route('/api/contact', async (route) => {
|
||||
routeHit = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, message: '发送成功' }),
|
||||
});
|
||||
});
|
||||
|
||||
await fillContactForm(page);
|
||||
|
||||
const submitButton = page.locator('[data-testid="submit-button"]');
|
||||
await submitButton.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 隐藏可能覆盖底部元素的固定返回顶部按钮
|
||||
await page.evaluate(() => {
|
||||
const backToTop = document.querySelector('[aria-label="返回顶部"]') as HTMLElement | null;
|
||||
if (backToTop) backToTop.style.display = 'none';
|
||||
});
|
||||
|
||||
// 使用原生 DOM click() 触发提交,避免 Playwright 命中测试在按钮 flex 内容上失效
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
|
||||
btn?.click();
|
||||
});
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
console.log('disabled 100ms after click:', await submitButton.isDisabled(), 'routeHit:', routeHit);
|
||||
|
||||
// 提交后立即检查按钮是否被禁用
|
||||
await expect(submitButton).toBeDisabled({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 3. 完整用户路径 ====================
|
||||
test.describe('关键用户路径 - 首页 → 产品 → 详情', () => {
|
||||
|
||||
test('从首页导航到产品列表页', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 桌面端导航中「产品」为下拉菜单,鼠标悬停展开后选择「查看全部产品」
|
||||
const desktopNav = page.locator('[data-testid="desktop-navigation"]').first();
|
||||
const productDropdown = desktopNav.locator('button:has-text("产品")').first();
|
||||
await expect(productDropdown).toBeVisible({ timeout: 5000 });
|
||||
await productDropdown.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const viewAllLink = desktopNav.locator('a:has-text("查看全部产品")').first();
|
||||
await expect(viewAllLink).toBeVisible({ timeout: 5000 });
|
||||
// 鼠标悬停在链接上,防止下拉菜单因 mouseleave 关闭
|
||||
await viewAllLink.hover();
|
||||
await page.waitForTimeout(200);
|
||||
await viewAllLink.click();
|
||||
|
||||
await page.waitForURL(/\/products/, { timeout: 10000 });
|
||||
await expect(page).toHaveURL(/\/products/);
|
||||
|
||||
const pageTitle = page.locator('h1, h2').first();
|
||||
await expect(pageTitle).toBeVisible();
|
||||
});
|
||||
|
||||
test('从产品列表页进入产品详情页', async ({ page }) => {
|
||||
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
|
||||
const linkCount = await productLinks.count();
|
||||
expect(linkCount).toBeGreaterThan(0);
|
||||
|
||||
const firstProduct = productLinks.first();
|
||||
await firstProduct.click();
|
||||
|
||||
await page.waitForURL(/\/products\/.+/, { timeout: 10000 });
|
||||
await expect(page).toHaveURL(/\/products\/.+/);
|
||||
|
||||
// 详情页应包含返回按钮或产品标题
|
||||
const detailContent = page.locator('main').first();
|
||||
await expect(detailContent).toBeVisible();
|
||||
const detailText = await detailContent.textContent();
|
||||
expect(detailText!.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('从产品详情页可跳转回产品列表', async ({ page }) => {
|
||||
await page.goto('/products/erp', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 查找返回或面包屑链接
|
||||
const backLink = page.locator('a[href="/products"], a:has-text("返回")').first();
|
||||
if (await backLink.count() > 0 && await backLink.isVisible()) {
|
||||
await backLink.click();
|
||||
await page.waitForURL(/\/products/, { timeout: 10000 });
|
||||
await expect(page).toHaveURL(/\/products/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 4. 服务详情页滚动进度条 ====================
|
||||
test.describe('服务详情页 - 滚动进度条', () => {
|
||||
|
||||
test('服务详情页存在滚动进度条', async ({ page }) => {
|
||||
await page.goto('/services', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const serviceLinks = page.locator('a[href*="/services/"]:not([href$="/services"])');
|
||||
if (await serviceLinks.count() > 0) {
|
||||
await serviceLinks.first().click();
|
||||
await page.waitForURL(/\/services\/.+/, { timeout: 10000 });
|
||||
} else {
|
||||
await page.goto('/services/digital-transformation', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// 服务详情页应渲染 ScrollProgress
|
||||
const progressBar = page.locator('[data-testid="scroll-progress"]');
|
||||
await expect(progressBar).toBeVisible();
|
||||
|
||||
const progressFill = progressBar.locator('> div').first();
|
||||
|
||||
// 滚动后缩放应变化
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scale = await progressFill.evaluate((el) => {
|
||||
const transform = (el as HTMLElement).style.transform;
|
||||
const match = transform.match(/scaleX\(([\d.]+)\)/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
});
|
||||
expect(scale).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['list', { printSteps: true }]],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
actionTimeout: 15000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
expect: {
|
||||
timeout: 15000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user