test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+78
View File
@@ -0,0 +1,78 @@
// @ts-nocheck
/**
* 在**进程内**用真实 Prisma 客户端把 schema 应用到一次性临时 SQLite 文件。
*
* 由 config/test/itest/global-setup.js 通过 `npx tsx` 调用(与项目既有约定一致:
* package.json 的 db:seed / check:contrast 同样用 `npx tsx` 跑 TS 脚本)。
*
* 为什么不用 `prisma db push`:db push 会自行解析数据源并连接,无法在「连接之前」证明它
* 打开的是哪个文件。本脚本显式传入 `datasourceUrl`,并在建表后用
* `PRAGMA database_list` 让 **SQLite 自己回答**它打开了哪个文件,再断言该路径位于
* realpath(os.tmpdir()) 之下 —— 这是比「事后检查」更强的保证。
*/
import { createRequire } from 'node:module';
import path from 'node:path';
import fs from 'node:fs';
const require = createRequire(import.meta.url);
const guard = require('./env.js');
// 真实(非 mock)Prisma 客户端:与 src/lib/db.ts 走同一个生成产物
const { PrismaClient } = require(path.join(guard.REPO_ROOT, 'src', 'generated', 'prisma', 'client.ts'));
const url = process.env.DATABASE_URL;
const ddlFile = process.env.ITEST_DDL_FILE;
const resultFile = process.env.ITEST_RESULT_FILE;
/** 与 src/lib/db.ts 完全一致的构造方式(无参构造 → 读 env DATABASE_URL),
* 唯一区别是这里额外显式传 datasourceUrl,让「指向哪里」在代码里可读可审。 */
const client = new PrismaClient({ datasourceUrl: url });
const resolvedUrl = guard.assertSafeDatasourceUrl(url, 'apply-schema');
const statements = guard.splitStatements(fs.readFileSync(ddlFile, 'utf8'));
const applied = [];
for (const statement of statements) {
await client.$executeRawUnsafe(statement);
applied.push(statement.split('\n')[0].slice(0, 60));
}
// SQLite 自报打开的文件 —— 断言它确实是临时库,而不是 prisma/dev.db。
const dbList = await client.$queryRawUnsafe('PRAGMA database_list');
const openedFile = dbList?.[0]?.file;
if (typeof openedFile !== 'string') {
throw new Error(`[itest-guard] PRAGMA database_list 未返回文件路径:${JSON.stringify(dbList)}`);
}
const verifiedOpen = guard.assertSafeDatasourceUrl(`file:${openedFile}`, 'database_list');
const tables = await client.$queryRawUnsafe(
`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`,
);
// 建表后复查项目库指纹:db push/diff 期间不得改动 prisma/dev.db。
const after = guard.devDbBaselines();
const baseline = JSON.parse(fs.readFileSync(process.env.ITEST_BASELINE_FILE, 'utf8'));
const drift = Object.keys(baseline).filter((key) => !guard.sameFingerprint(baseline[key], after[key]));
if (drift.length > 0) {
throw new Error(`[itest-guard] 项目数据源被改动:${drift.join(', ')}`);
}
await client.$disconnect();
fs.writeFileSync(
resultFile,
JSON.stringify({
ok: true,
datasourceUrl: url,
resolvedUrl,
openedFile: verifiedOpen,
statementCount: statements.length,
applied,
tables: tables.map((t) => t.name),
devDb: after,
}, null, 2),
);
console.log('[itest] schema applied');
console.log('[itest] DATABASE_URL =', url);
console.log('[itest] sqlite opened', openedFile);
console.log('[itest] tables =', tables.map((t) => t.name).join(','));
+147
View File
@@ -0,0 +1,147 @@
/**
* 集成测试(真库)安全边界工具 —— 单一真源。
*
* 背景:验收报告 ACCEPTANCE_REVIEW_2026-09-21.md A-8 指出 jest.setup.js:12-50 全局 mock 了
* `@/generated/prisma/client` 与 `@/lib/cms/data-server`,因此整个单测套件从未触碰真实数据层。
* 本目录(config/test/itest/)提供一套独立的 Jest project,跑在**真实但一次性**的 SQLite 文件上。
*
* 硬安全约束(共享开发机):本文件的存在意义就是保证
* prisma/dev.db / dev.db / data.db / 任何 .env* 永远不可能被集成测试打开或写入。
* 实现方式:只允许 `file:<绝对路径>` 且绝对路径必须位于 realpath(os.tmpdir()) 之下。
* 任何不满足条件的 URL 立刻 throw —— globalSetup / 每个 worker 的 setupFiles / teardown 三处分别断言。
*/
// @ts-nocheck
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/** 仓库根(config/test/itest/env.js → 上三级) */
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
/**
* 临时目录选择:优先 /tmp(任务书要求的落点;macOS 上 realpath 后为 /private/tmp,
* 与 os.tmpdir() 的 per-user var/folders 相比更可预测),不可写时回落 os.tmpdir()。
* 可用 ITEST_TMP_DIR 显式覆盖(CI 里指向专用挂载点)。
* 无论选哪个,后续所有断言都基于 realpath 后的结果。
*/
function pickTmpDir() {
const { constants } = fs;
const candidates = [process.env.ITEST_TMP_DIR, '/tmp', os.tmpdir()].filter(Boolean);
for (const dir of candidates) {
try {
fs.accessSync(dir, constants.W_OK | constants.R_OK);
return fs.realpathSync(dir);
} catch {
/* 下一个候选 */
}
}
throw new Error('[itest-guard] 找不到可写的临时目录');
}
const TMP_REALPATH = pickTmpDir();
/** 一次性集成库的固定路径(任务书建议的 /tmp/novalon-itest.db) */
const DB_PATH = path.join(TMP_REALPATH, 'novalon-itest.db');
/** globalSetup 写入、teardown 读取的运行期清单(含 dev.db 基线指纹) */
const MANIFEST_PATH = path.join(TMP_REALPATH, 'novalon-itest-manifest.json');
/** migrate diff 输出的 DDL 脚本(只写 /tmp,不写仓库) */
const DDL_PATH = path.join(TMP_REALPATH, 'novalon-itest-schema.sql');
/** 项目自身的数据源候选 —— 任何一个被集成测试打开都属安全事故 */
const FORBIDDEN_DB_BASENAMES = ['dev.db', 'data.db', 'test.db', 'prod.db', 'novalon.db'];
const FORBIDDEN_DB_PATHS = [
path.join(REPO_ROOT, 'prisma', 'dev.db'),
path.join(REPO_ROOT, 'dev.db'),
path.join(REPO_ROOT, 'data.db'),
];
/** 记录一个库文件的指纹(不存在时返回 null,同样可作为「未被创建」的证据) */
function fingerprint(file) {
try {
const st = fs.statSync(file);
return { path: file, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, exists: true };
} catch {
return { path: file, ino: null, size: null, mtimeMs: null, exists: false };
}
}
function devDbBaselines() {
return {
prismaDevDb: fingerprint(path.join(REPO_ROOT, 'prisma', 'dev.db')),
rootDevDb: fingerprint(path.join(REPO_ROOT, 'dev.db')),
rootDataDb: fingerprint(path.join(REPO_ROOT, 'data.db')),
};
}
function sameFingerprint(a, b) {
if (!a || !b) return false;
if (a.exists !== b.exists) return false;
if (!a.exists) return true;
return a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
}
/**
* 唯一允许的 URL 形态:`file:` + 绝对路径 + 位于真实临时目录之下。
* 注意 `file:./dev.db` 这类相对写法在 Prisma 里按 schema 目录解析,会命中 prisma/dev.db,
* 因此这里要求绝对路径,不做任何相对解析。
*/
function assertSafeDatasourceUrl(url, where) {
const label = `[itest-guard:${where}]`;
if (typeof url !== 'string' || url.length === 0) {
throw new Error(`${label} DATABASE_URL 未设置:集成测试拒绝在无显式数据源的情况下运行`);
}
if (!url.startsWith('file:')) {
throw new Error(`${label} 集成测试只允许 file: 协议,收到 ${url}`);
}
const raw = url.slice('file:'.length).split('?')[0];
if (!path.isAbsolute(raw)) {
throw new Error(`${label} file: URL 必须是绝对路径(相对路径会被 Prisma 按 prisma/ 目录解析,可能命中 dev.db),收到 ${url}`);
}
const resolved = fs.existsSync(raw) ? fs.realpathSync(raw) : path.resolve(raw);
if (!resolved.startsWith(TMP_REALPATH + path.sep)) {
throw new Error(`${label} 集成库必须位于临时目录 ${TMP_REALPATH} 之下,解析结果为 ${resolved}`);
}
if (FORBIDDEN_DB_BASENAMES.includes(path.basename(resolved))) {
throw new Error(`${label} 命中禁用库名 ${path.basename(resolved)}`);
}
if (FORBIDDEN_DB_PATHS.includes(resolved)) {
throw new Error(`${label} 命中项目数据源 ${resolved}`);
}
// 仓库内的任何路径都不允许作为集成库(防止误把 DATABASE_URL 指回 prisma/dev.db)
if (resolved.startsWith(REPO_ROOT + path.sep)) {
throw new Error(`${label} 集成库不得位于仓库内,解析结果为 ${resolved}`);
}
return resolved;
}
const DATASOURCE_URL = `file:${DB_PATH}`;
/** 把 migrate diff 的 --script 输出切成可逐条执行的语句 */
function splitStatements(sql) {
return sql
.split('\n')
.filter((line) => !line.startsWith('--') && line.trim().length > 0)
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
module.exports = {
REPO_ROOT,
TMP_REALPATH,
DB_PATH,
DDL_PATH,
MANIFEST_PATH,
DATASOURCE_URL,
FORBIDDEN_DB_PATHS,
assertSafeDatasourceUrl,
fingerprint,
devDbBaselines,
sameFingerprint,
splitStatements,
};
+122
View File
@@ -0,0 +1,122 @@
// @ts-nocheck
/**
* 集成测试 globalSetup:创建 /tmp 一次性 SQLite 库并应用 Prisma schema。
*
* 步骤(每一步都可审计):
* 1. 记录项目数据源(prisma/dev.db、<root>/dev.db、<root>/data.db)指纹基线。
* 2. 用 `prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script`
* 离线生成最终 DDL —— 该命令只输出 SQL 文本,不连接任何数据库。作为第二道保险,
* 子进程的 DATABASE_URL 同样被强制指向临时库,并在执行后复验项目库指纹未变。
* 3. 用 `npx tsx config/test/itest/apply-schema.mts` 在进程内以真实 Prisma 客户端把 DDL
* 应用到临时库,并由 SQLite 的 PRAGMA database_list 自证它打开的是哪个文件。
* 4. 复验项目库指纹,写入 manifest(worker 的 setupFiles 与 globalTeardown 都读它)。
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const guard = require('./env.js');
function stamp(msg) {
console.log(`[itest:setup] ${msg}`);
}
function assertProjectDbUntouched(baseline, where) {
const now = guard.devDbBaselines();
const drift = Object.keys(baseline).filter((k) => !guard.sameFingerprint(baseline[k], now[k]));
if (drift.length > 0) {
throw new Error(`[itest:setup] ${where} 之后项目数据源指纹发生变化:${JSON.stringify(drift)}`);
}
return now;
}
module.exports = async function globalSetup() {
// 0) URL 合法性先于任何文件操作
guard.assertSafeDatasourceUrl(guard.DATASOURCE_URL, 'globalSetup');
const baseline = guard.devDbBaselines();
stamp(`tmp realpath = ${guard.TMP_REALPATH}`);
stamp(`integration db = ${guard.DB_PATH}`);
stamp(`prisma/dev.db fp = ${JSON.stringify(baseline.prismaDevDb)}`);
// 1) 干净起步:只删除 /tmp 下属于本次运行的文件
for (const f of [
guard.DB_PATH,
`${guard.DB_PATH}-journal`,
`${guard.DB_PATH}-wal`,
`${guard.DB_PATH}-shm`,
guard.DDL_PATH,
]) {
if (!f.startsWith(`${guard.TMP_REALPATH}${path.sep}`)) {
throw new Error(`[itest:setup] 拒绝删除非临时目录文件 ${f}`);
}
try {
fs.rmSync(f);
} catch {
/* 不存在即正常 */
}
}
// 2) 离线生成 DDL(migrate diff 不连接数据库;DATABASE_URL 仍指向临时库作为第二道保险)
const diffOut = execFileSync(
'npx',
[
'prisma', 'migrate', 'diff',
'--from-empty',
'--to-schema-datamodel', 'prisma/schema.prisma',
'--script',
],
{
cwd: guard.REPO_ROOT,
encoding: 'utf8',
env: { ...process.env, DATABASE_URL: guard.DATASOURCE_URL },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const statements = guard.splitStatements(diffOut);
if (statements.length < 5) {
throw new Error(`[itest:setup] migrate diff 产出的语句数量异常(${statements.length}),拒绝继续`);
}
fs.writeFileSync(guard.DDL_PATH, diffOut);
stamp(`ddl statements = ${statements.length}`);
assertProjectDbUntouched(baseline, 'migrate diff');
// 3) 进程内应用 schema
const resultFile = `${guard.MANIFEST_PATH}.apply.json`;
const baselineFile = `${guard.MANIFEST_PATH}.baseline.json`;
try { fs.rmSync(resultFile); } catch { /* ignore */ }
fs.writeFileSync(baselineFile, JSON.stringify(baseline));
execFileSync('npx', ['tsx', path.join(__dirname, 'apply-schema.mts')], {
cwd: guard.REPO_ROOT,
encoding: 'utf8',
env: {
...process.env,
DATABASE_URL: guard.DATASOURCE_URL,
ITEST_DDL_FILE: guard.DDL_PATH,
ITEST_RESULT_FILE: resultFile,
ITEST_BASELINE_FILE: baselineFile,
},
stdio: ['ignore', 'inherit', 'inherit'],
});
const applyResult = JSON.parse(fs.readFileSync(resultFile, 'utf8'));
if (!applyResult.ok) throw new Error('[itest:setup] apply-schema 未报告成功');
stamp(`sqlite opened file= ${applyResult.openedFile}`);
stamp(`tables = ${applyResult.tables.join(',')}`);
// 4) 复验 + manifest 落盘
const afterSetup = assertProjectDbUntouched(baseline, 'apply-schema');
fs.writeFileSync(
guard.MANIFEST_PATH,
JSON.stringify(
{ datasourceUrl: guard.DATASOURCE_URL, baseline, afterSetup, applyResult, startedAt: new Date().toISOString() },
null,
2,
),
);
process.env.DATABASE_URL = guard.DATASOURCE_URL;
process.env.NOVALON_ITEST_MANIFEST = guard.MANIFEST_PATH;
};
+59
View File
@@ -0,0 +1,59 @@
// @ts-nocheck
/**
* 集成测试 globalTeardown:删除一次性临时库,并最终复验项目数据源从未被打开或写入。
* 若发现指纹漂移,这里直接抛错让整条流水线变红 —— 安全保证必须是可失败的断言,
* 而不是日志里的温馨提示。
*/
const fs = require('fs');
const path = require('path');
const guard = require('./env.js');
function stamp(msg) {
console.log(`[itest:teardown] ${msg}`);
}
module.exports = async function globalTeardown() {
let manifest = null;
try {
manifest = JSON.parse(fs.readFileSync(guard.MANIFEST_PATH, 'utf8'));
} catch {
stamp('未找到 manifest(globalSetup 可能未执行);仍执行清理与守卫');
}
const now = guard.devDbBaselines();
if (manifest?.baseline) {
const drift = Object.keys(manifest.baseline).filter(
(k) => !guard.sameFingerprint(manifest.baseline[k], now[k]),
);
if (drift.length > 0) {
throw new Error(
`[itest:teardown] 安全事故:项目数据源在集成测试期间被改动 → ${JSON.stringify(drift, null, 2)}`,
);
}
}
for (const f of [
guard.DB_PATH,
`${guard.DB_PATH}-journal`,
`${guard.DB_PATH}-wal`,
`${guard.DB_PATH}-shm`,
guard.DDL_PATH,
guard.MANIFEST_PATH,
`${guard.MANIFEST_PATH}.apply.json`,
`${guard.MANIFEST_PATH}.baseline.json`,
]) {
if (!f.startsWith(`${guard.TMP_REALPATH}${path.sep}`)) {
throw new Error(`[itest:teardown] 拒绝删除非临时目录文件 ${f}`);
}
try {
fs.rmSync(f);
stamp(`removed ${f}`);
} catch {
/* 已不存在 */
}
}
stamp(`project dbs after run = ${JSON.stringify(now)}`);
stamp('守卫通过:prisma/dev.db / dev.db / data.db 的 inode+size+mtime 与运行前完全一致');
};
@@ -0,0 +1,48 @@
// @ts-nocheck
/**
* src/generated/prisma/** 的专用 Jest transformer。
*
* Prisma 6 的 `prisma-client` generator 产出的是 ESM,`client.ts:16` 使用
* `fileURLToPath(import.meta.url)` 求 `__dirname`。Jest 的 CJS 运行时无法直接求值
* `import.meta`(TS 在 module=commonjs 下报 TS1343,Node 报语法错误)。
*
* 处理方式:在转译前把 `import.meta.url` 等价替换为 `require('url').pathToFileURL(__filename).href`
* —— 语义完全相同(当前模块文件的 file: URL),且不改动仓库里的生成产物。
* 仅对生成目录生效,src 下其余文件仍走 ts-jest(保留类型检查)。
*/
const ts = require('typescript');
const IMPORT_META_URL = /import\.meta\.url/g;
module.exports = {
getCacheKey(sourceText, sourcePath) {
return String(require('crypto')
.createHash('md5')
.update(sourceText)
.update('\0')
.update(String(sourcePath))
.update('\0')
.update('prisma-generated-transformer-v1'));
},
process(sourceText, sourcePath) {
const patched = sourceText.includes('import.meta.url')
? sourceText.replace(IMPORT_META_URL, "require('url').pathToFileURL(__filename).href")
: sourceText;
const out = ts.transpileModule(patched, {
fileName: String(sourcePath),
reportDiagnostics: false,
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
allowJs: true,
isolatedModules: true,
importNotAsAccessibleInCjs: undefined,
},
});
return { code: out.outputText };
},
};
+52
View File
@@ -0,0 +1,52 @@
// @ts-nocheck
/**
* 每个 Jest worker 在任何被测模块被 import 之前执行(setupFiles 阶段)。
*
* 职责:把数据源**无条件**钉死到 /tmp 的一次性库,并在钉死之前先做守卫断言。
* 这一步是必须的:src/lib/db.ts 里 `new PrismaClient()` 无参构造,运行时读 env("DATABASE_URL"),
* 只要该变量残留成 prisma/dev.db,测试就会写真库 —— 所以宁可在这里直接抛错。
*/
const fs = require('fs');
const { TextEncoder, TextDecoder } = require('util');
const guard = require('./env.js');
// 1) 取 globalSetup 落盘的 URL;拿不到就退回固定路径,但两种来源都必须通过守卫
let url = process.env.DATABASE_URL;
try {
const manifest = JSON.parse(fs.readFileSync(guard.MANIFEST_PATH, 'utf8'));
url = manifest.datasourceUrl;
} catch {
url = guard.DATASOURCE_URL;
}
// 2) 守卫:绝对 file: URL、位于 realpath(os.tmpdir()) 之下、不是仓库内路径、不是 dev.db/data.db
const resolved = guard.assertSafeDatasourceUrl(url, 'setupFiles');
// 3) 项目库指纹必须仍与 setup 阶段一致(worker 起来就已经是最早的写入者之前)
try {
const manifest = JSON.parse(fs.readFileSync(guard.MANIFEST_PATH, 'utf8'));
const now = guard.devDbBaselines();
const drift = Object.keys(manifest.baseline || {}).filter(
(k) => !guard.sameFingerprint(manifest.baseline[k], now[k]),
);
if (drift.length > 0) {
throw new Error(`[itest:setupFiles] 项目数据源指纹漂移:${drift.join(', ')}`);
}
} catch (e) {
if (String(e.message).includes('指纹漂移')) throw e;
/* manifest 缺失时由 globalSetup 的断言负责 */
}
process.env.DATABASE_URL = url;
process.env.NOVALON_ITEST_DB = resolved;
// 测试专用 JWT 密钥:刻意不读 .env*,保证集成层与本机开发密钥完全隔离
process.env.JWT_SECRET = 'novalon-integration-only-access-secret';
process.env.JWT_REFRESH_SECRET = 'novalon-integration-only-refresh-secret';
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000';
// 4) jsdom 环境的最小 Node 能力补齐(与 jest.setup.js:4-6 同源,但本文件不加载全局 mock)
if (typeof globalThis.TextEncoder === 'undefined') globalThis.TextEncoder = TextEncoder;
if (typeof globalThis.TextDecoder === 'undefined') globalThis.TextDecoder = TextDecoder;
console.log(`[itest:worker] DATABASE_URL pinned to ${url} (resolved ${resolved})`);
+75 -74
View File
@@ -16,96 +16,91 @@ module.exports = {
'src/components/sections/**/*.{ts,tsx}',
'src/components/content/**/*.{ts,tsx}',
'src/components/analytics/**/*.{ts,tsx}',
'src/components/cms/**/*.{ts,tsx}',
'src/hooks/**/*.{ts,tsx}',
'src/lib/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
'!src/**/__tests__/**',
],
// 覆盖率阈值采用渐进提升策略:基于 Phase 7 实测值设定目录级阈值,避免全局平均掩盖核心模块缺口
// 实测基准 (Phase 7 — 2026-08-02): global(72.65% stmts/82.20% branches/73.56% funcs),
// ui(63.91%/76.52%), layout(71.02%/76.92%), detail(74.20%/75.96%), sections(43.24%/78.09%),
// seo(100%/100%), content(56.15%/100%), analytics(已覆盖全部 7 组件),
// lib(95.96%/89.60%), lib/cms(91.22%/90.90%), lib/constants(98.78%/91.89%)
// 覆盖率棘轮(验收 A-7):阈值取自实测值下调 3–5 个点,只允许上升。
// 本文件是阈值的单一真源;根 jest.config.js 仅转发(旧注释曾把路径写成 config/test/ 之外的地方,已纠)。
// 复测命令:npm run test:coverage(最近一次 2026-09-23,134 suites / 1697 tests,EXIT=0)
// 实测 global: stmts 85.56 / branch 86.86 / funcs 80.59 / lines 85.56
// 目录级最近实测:components/content 100/100/100/100 · components/sections 72.77/87.06/73.8/72.77 · lib/cms 91.9/92.63/92.5/91.9
// ⚠ 口径提示:global 四项实测已比门限高 5.6–10.6 个点,即棘轮当前偏松(低于「实测 −5pp」的本文件自订策略)。
// 上调是独立动作,须与「哪些目录仍可约束」一起评估,不与功能变更混提。
coverageThreshold: {
global: {
branches: 70,
functions: 55,
lines: 55,
statements: 55,
branches: 82,
functions: 75,
lines: 75,
statements: 75,
},
'./src/hooks/': {
branches: 38,
functions: 60,
lines: 60,
statements: 60,
branches: 88,
functions: 85,
lines: 88,
statements: 88,
},
'./src/lib/': {
branches: 40,
functions: 45,
lines: 42,
statements: 42,
},
'./src/components/ui/': {
branches: 45,
functions: 55,
lines: 35,
statements: 35,
},
'./src/components/layout/': {
branches: 60,
functions: 35,
lines: 60,
statements: 60,
},
'./src/components/detail/': {
branches: 60,
functions: 45,
lines: 60,
statements: 60,
},
'./src/components/sections/': {
branches: 60,
functions: 30,
lines: 35,
statements: 35,
},
'./src/components/content/': {
branches: 80,
functions: 40,
lines: 45,
statements: 45,
},
'./src/components/analytics/': {
branches: 30,
functions: 40,
lines: 35,
statements: 35,
},
'./src/components/cms/': {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/components/seo/': {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
branches: 86,
functions: 85,
lines: 92,
statements: 92,
},
'./src/lib/cms/': {
branches: 80,
functions: 75,
lines: 60,
statements: 65,
branches: 86,
functions: 86,
lines: 88,
statements: 88,
},
'./src/lib/constants/': {
branches: 88,
functions: 45,
lines: 85,
statements: 85,
functions: 55,
lines: 92,
statements: 92,
},
'./src/components/ui/': {
branches: 74,
functions: 76,
lines: 62,
statements: 62,
},
'./src/components/layout/': {
branches: 66,
functions: 46,
lines: 82,
statements: 82,
},
'./src/components/detail/': {
branches: 80,
functions: 42,
lines: 80,
statements: 80,
},
'./src/components/sections/': {
branches: 72,
functions: 50,
lines: 44,
statements: 44,
},
'./src/components/content/': {
branches: 95,
functions: 46,
lines: 52,
statements: 52,
},
'./src/components/analytics/': {
branches: 82,
functions: 86,
lines: 94,
statements: 94,
},
'./src/components/seo/': {
branches: 95,
functions: 95,
lines: 95,
statements: 95,
},
},
coverageProvider: 'v8',
@@ -119,9 +114,15 @@ module.exports = {
// ts-jest 需要明确指定 jsx: 'react-jsx',因为项目 tsconfig 使用 'preserve'
// 由后续 SWC/Babel 处理,但 Jest 环境下没有这些工具链
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
// sanitize-html 的解析器链(htmlparser2 v12 起)为 ESM-only,Node 可靠 require(esm) 加载,
// Jest 的 CJS 运行时不行,须显式转译(作用域限定在这几个包,不影响其余 node_modules)
'node_modules/(?:htmlparser2|domhandler|domutils|dom-serializer|domelementtype|entities)/.+\\.js$': [
'ts-jest',
{ tsconfig: { allowJs: true, checkJs: false, module: 'commonjs', esModuleInterop: true, target: 'es2020' } },
],
},
transformIgnorePatterns: [
'node_modules/(?!(nanoid|next-auth|@auth|@aws-sdk)/)',
'node_modules/(?!(nanoid|next-auth|@auth|@aws-sdk|htmlparser2|domhandler|domutils|dom-serializer|domelementtype|entities)/)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testTimeout: 10000,
+58
View File
@@ -0,0 +1,58 @@
// @ts-nocheck
// 集成测试 Jest project —— 唯一跑真实数据层的测试配置。
//
// 与 config/test/jest.config.js(单元测试)的关系:完全独立,不复用 jest.setup.js,
// 因为 jest.setup.js:12-50 全局 mock 了 `@/generated/prisma/client` 与
// `@/lib/cms/data-server`(验收报告 ACCEPTANCE_REVIEW_2026-09-21.md A-8)。
// 本配置不加载任何全局 mock,因此 src/lib/db.ts 导出的是真实的 PrismaClient。
//
// 文件发现:testMatch 只匹配 tests-integration 下的 .itest.ts。
// 单测配置的 roots 是 <rootDir>/src,testMatch 是 *.test.ts(x);
// `.itest.ts` 既不以 `.test.ts` 结尾、也不在 src/ 下,故 `npm run test:unit`、
// `test:coverage`、stryker(同样读 config/test/jest.config.js)都不会发现这些用例 —— 行为零变化。
//
// 运行:npx jest --config config/test/jest.integration.config.js
const path = require('path');
module.exports = {
rootDir: path.resolve(__dirname, '../..'),
roots: ['<rootDir>/tests-integration'],
testMatch: ['<rootDir>/tests-integration/**/*.itest.ts'],
// testEnvironment 与单测配置(config/test/jest.config.js:6)的 jsdom **有意不同**,原因是硬约束:
// jest-environment-jsdom 30 的沙箱里没有 Request/Response/Headers(Node 主 realm 才有),
// 于是 `import { NextRequest } from 'next/server'` 直接 `ReferenceError: Request is not defined`
// (node_modules/next/src/server/web/spec-extension/request.ts:14)。
// 这正是既有「route.test.ts」宁愿手写 global.Request 假对象(jest.setup.js:185-232)并整体 mock
// 掉 next/server(jest.setup.js:90-109)的原因 —— 而那类测试测不到真实的 HTTP 契约。
// 被测对象是跑在 Node runtime 的 route handler + 真实 SQLite,所以集成层用 node 环境,
// 从而拿到真实的 NextRequest/NextResponse(含 cookies / headers / clone 语义)。
testEnvironment: 'node',
globalSetup: '<rootDir>/config/test/itest/global-setup.js',
globalTeardown: '<rootDir>/config/test/itest/global-teardown.js',
// 每个 worker 在 import 被测模块之前把 DATABASE_URL 钉到 /tmp 并做守卫断言
setupFiles: ['<rootDir>/config/test/itest/setup-files.js'],
setupFilesAfterEnv: [],
collectCoverage: false,
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
// 与 Next 运行时对齐的真实 React:src/lib/cms/data-server.ts:6 用 `import { cache } from 'react'`,
// 而仓库依赖的 node_modules/react@18.3.1 **不导出 cache**(实测 typeof require('react').cache
// === 'undefined';其 react-server 入口 react.shared-subset.js 直接抛
// "This entry point is not yet supported outside of experimental channels")。
// 应用能跑是因为 Next 把 react 解析到自己内置的 19.3.0-canary。这里做同样的解析,
// 集成层才能 import 真实 data-server —— 而不是像 jest.setup.js:29-50 那样把它整体 mock 掉。
'^react$': require.resolve('next/dist/compiled/react'),
},
transform: {
// Prisma 生成产物是 ESM 且使用 import.meta.url(src/generated/prisma/client.ts:16),
// 在 CJS 的 Jest 运行时里必须由专用 transformer 处理;该目录是机器生成的代码,
// 只做转译不做类型检查(文件自身带 @ts-nocheck)。
'src/generated/prisma/.+\\.ts$': '<rootDir>/config/test/itest/prisma-generated-transformer.js',
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
},
// SQLite 单写者 + 固定文件名:串行执行,避免多个 worker 同时写同一个临时库
maxWorkers: 1,
testTimeout: 30000,
verbose: true,
};
-205
View File
@@ -1,205 +0,0 @@
// @ts-nocheck
require('@testing-library/jest-dom');
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => 'test-id-123'),
}));
jest.mock('@/generated/prisma/client', () => ({
PrismaClient: jest.fn().mockImplementation(() => ({
contentItem: {
findMany: jest.fn().mockResolvedValue([]),
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(null),
},
contentModel: {
findMany: jest.fn().mockResolvedValue([]),
},
contentZone: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
},
})),
}));
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn().mockResolvedValue([]),
getPublishedItemBySlug: jest.fn().mockResolvedValue(null),
getItemById: jest.fn().mockResolvedValue(null),
getPageZones: jest.fn().mockResolvedValue([]),
getZone: jest.fn().mockResolvedValue(null),
getContentModels: jest.fn().mockResolvedValue([]),
getCases: jest.fn().mockResolvedValue([]),
getNews: jest.fn().mockResolvedValue([]),
getServices: jest.fn().mockResolvedValue([]),
getProducts: jest.fn().mockResolvedValue([]),
getSolutions: jest.fn().mockResolvedValue([]),
getStats: jest.fn().mockResolvedValue([]),
getHeroBanners: jest.fn().mockResolvedValue([]),
getCaseBySlug: jest.fn().mockResolvedValue(null),
getNewsBySlug: jest.fn().mockResolvedValue(null),
getServiceBySlug: jest.fn().mockResolvedValue(null),
getProductBySlug: jest.fn().mockResolvedValue(null),
getSolutionBySlug: jest.fn().mockResolvedValue(null),
getHomePageZones: jest.fn().mockResolvedValue([]),
getAllPublishedSlugs: jest.fn().mockResolvedValue([]),
}));
jest.mock('next/dynamic', () => ({
__esModule: true,
default: (_importFn, _options) => {
const MockComponent = (_props) => null;
MockComponent.displayName = 'DynamicComponent';
MockComponent.preload = () => Promise.resolve();
return MockComponent;
},
}));
jest.mock('next/server', () => ({
NextRequest: class MockNextRequest {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
},
NextResponse: {
json: (body, init = {}) => ({
status: init.status || 200,
json: async () => body,
}),
},
}));
global.console = {
...console,
error: jest.fn(),
warn: jest.fn(),
log: jest.fn(),
};
class MockIntersectionObserver {
constructor(callback, options = {}) {
this.callback = callback;
this.options = options;
this.elements = new Set();
this.observationEntries = [];
}
observe(element) {
this.elements.add(element);
const entry = {
isIntersecting: true,
target: element,
boundingClientRect: element.getBoundingClientRect ? element.getBoundingClientRect() : {},
intersectionRatio: 1,
intersectionRect: {},
rootBounds: {},
time: Date.now(),
};
this.observationEntries.push(entry);
this.callback(this.observationEntries, this);
}
unobserve(element) {
this.elements.delete(element);
this.observationEntries = this.observationEntries.filter(
entry => entry.target !== element
);
}
disconnect() {
this.elements.clear();
this.observationEntries = [];
}
takeRecords() {
return this.observationEntries;
}
}
global.IntersectionObserver = MockIntersectionObserver;
global.IntersectionObserverEntry = class IntersectionObserverEntry {
constructor() {
this.isIntersecting = true;
this.target = {};
this.boundingClientRect = {};
this.intersectionRatio = 1;
this.intersectionRect = {};
this.rootBounds = {};
this.time = Date.now();
}
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
global.Request = class Request {
constructor(input, init = {}) {
this.url = typeof input === 'string' ? input : input.url;
this.method = init.method || 'GET';
this.headers = new Headers(init.headers);
this.body = init.body;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
};
global.Headers = class Headers {
constructor(init = {}) {
this.headers = {};
if (init) {
Object.entries(init).forEach(([key, value]) => {
this.headers[key.toLowerCase()] = value;
});
}
}
get(name) {
return this.headers[name.toLowerCase()];
}
set(name, value) {
this.headers[name.toLowerCase()] = value;
}
};
global.Response = class Response {
constructor(body, init = {}) {
this.body = body;
this.status = init.status || 200;
this.statusText = init.statusText || 'OK';
this.headers = new Headers(init.headers);
this.ok = this.status >= 200 && this.status < 300;
}
async json() {
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
}
async text() {
return String(this.body);
}
};
+74 -11
View File
@@ -2,16 +2,18 @@
"ci": {
"collect": {
"numberOfRuns": 3,
"startServerCommand": "npm run start",
"startServerCommand": "HOSTNAME=localhost PORT=3200 node dist/standalone/server.js",
"startServerReadyPattern": "Local:",
"url": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/services",
"http://localhost:3000/products",
"http://localhost:3000/cases",
"http://localhost:3000/news",
"http://localhost:3000/contact"
"http://localhost:3200/",
"http://localhost:3200/about",
"http://localhost:3200/services",
"http://localhost:3200/products",
"http://localhost:3200/cases",
"http://localhost:3200/news",
"http://localhost:3200/contact",
"http://localhost:3200/products/erp-upgrade",
"http://localhost:3200/about/brand"
],
"settings": {
"preset": "desktop",
@@ -34,14 +36,75 @@
"largest-contentful-paint": ["error", {"maxNumericValue": 3000}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 300}],
"speed-index": ["error", {"maxNumericValue": 3000}]
"speed-index": ["error", {"maxNumericValue": 3000}],
"inspector-issues": ["error", {"minScore": 1}],
"aria-allowed-attr": ["error", {"minScore": 1}],
"aria-hidden-body": ["error", {"minScore": 1}],
"aria-required-attr": ["error", {"minScore": 1}],
"aria-required-children": ["error", {"minScore": 1}],
"aria-required-parent": ["error", {"minScore": 1}],
"aria-roles": ["error", {"minScore": 1}],
"aria-valid-attr": ["error", {"minScore": 1}],
"aria-valid-attr-value": ["error", {"minScore": 1}],
"duplicate-id-aria": ["error", {"minScore": 1}],
"button-name": ["error", {"minScore": 1}],
"image-alt": ["error", {"minScore": 1}],
"input-button-name": ["error", {"minScore": 1}],
"input-image-alt": ["error", {"minScore": 1}],
"label": ["error", {"minScore": 1}],
"select-name": ["error", {"minScore": 1}],
"meta-refresh": ["error", {"minScore": 1}],
"meta-viewport": ["error", {"minScore": 1}],
"video-caption": ["error", {"minScore": 1}],
"accesskeys": ["error", {"minScore": 1}],
"aria-command-name": ["error", {"minScore": 1}],
"aria-conditional-attr": ["error", {"minScore": 1}],
"aria-hidden-focus": ["error", {"minScore": 1}],
"aria-input-field-name": ["error", {"minScore": 1}],
"aria-meter-name": ["error", {"minScore": 1}],
"aria-progressbar-name": ["error", {"minScore": 1}],
"aria-prohibited-attr": ["error", {"minScore": 1}],
"aria-toggle-field-name": ["error", {"minScore": 1}],
"bypass": ["error", {"minScore": 1}],
"color-contrast": ["error", {"minScore": 1}],
"definition-list": ["error", {"minScore": 1}],
"dlitem": ["error", {"minScore": 1}],
"document-title": ["error", {"minScore": 1}],
"frame-title": ["error", {"minScore": 1}],
"html-has-lang": ["error", {"minScore": 1}],
"html-lang-valid": ["error", {"minScore": 1}],
"link-in-text-block": ["error", {"minScore": 1}],
"link-name": ["error", {"minScore": 1}],
"list": ["error", {"minScore": 1}],
"listitem": ["error", {"minScore": 1}],
"object-alt": ["error", {"minScore": 1}],
"tabindex": ["error", {"minScore": 1}],
"target-size": ["error", {"minScore": 1}],
"td-headers-attr": ["error", {"minScore": 1}],
"th-has-data-cells": ["error", {"minScore": 1}],
"valid-lang": ["error", {"minScore": 1}],
"heading-order": ["error", {"minScore": 1}],
"form-field-multiple-labels": ["warn", {"minScore": 1}],
"html-xml-lang-mismatch": ["warn", {"minScore": 1}],
"landmark-one-main": ["warn", {"minScore": 1}],
"skip-link": ["warn", {"minScore": 1}],
"aria-deprecated-role": ["warn", {"minScore": 1}],
"aria-dialog-name": ["warn", {"minScore": 1}],
"aria-text": ["warn", {"minScore": 1}],
"aria-treeitem-name": ["warn", {"minScore": 1}]
}
},
"upload": {
"target": "temporary-public-storage"
"target": "filesystem",
"outputDir": "lighthouse-reports",
"reportFilenamePattern": "%%PATHNAME%%-%%DATETIME%%.report.%%EXTENSION%%"
},
"settings": {
"output": "html",
"output": ["html", "json"],
"outputPath": "lighthouse-reports"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { expect, type Locator } from '@playwright/test';
/**
* N-27 整改新增的共享断言辅助(2026-09-23)。
*
* 背景:多个 spec 曾以 `const box = await locator.boundingBox(); if (box) {...}`
* 守卫几何断言——高负载下 boundingBox 偶发返回 null(chain3 实测,见
* p3-compatibility「移动端导航可用」原注释),守卫会把「元素消失」这种真实回归
* 静默吞成绿色。此类站点统一改用 pollBoundingBox:
* - 轮询直到 boundingBox 非 null(超时 ⇒ 红色失败);
* - 轮询通过后若仍为 null(元素在断言瞬间被移除)⇒ 显式 throw,绝不静默跳过。
*
* 规则:本文件只做「等待」,不做「放宽」——不提供任何跳过断言的开关。
*/
export interface BoxLike {
x: number;
y: number;
width: number;
height: number;
}
/**
* 轮询 target 的 boundingBox 直到非 null,返回稳定后的盒模型。
* 超时未就绪时由 expect.poll 直接失败(测试变红),不会静默通过。
*/
export async function pollBoundingBox(
target: Locator,
timeout = 10000,
message = '等待 boundingBox 就绪',
): Promise<BoxLike> {
await expect
.poll(() => target.boundingBox(), { timeout, message })
.not.toBeNull();
const box = await target.boundingBox();
if (!box) {
throw new Error('boundingBox 在轮询通过后仍为 null(元素在断言瞬间消失)');
}
return box;
}
+40 -51
View File
@@ -1,61 +1,50 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('案例页行业筛选按钮可以正确过滤列表', { tag: '@regression' }, async ({ page }) => {
/**
* N-27 整改(2026-09-23):本文件原实现整段是假绿机——
* 1. 筛选 chip 在组件里是 `<button type="button" aria-pressed>`(cases-content-v3.tsx:266-281),
* 从不带 role="radio" ⇒ 原 `button[role="radio"]` 选择器恒为 0,early-return
* 分支(原 :20-26)只留一个 bodyText 长度断言。
* 2. 且 CMS 种子刻意 0 条案例(prisma/seed.ts:628-631「首批客户共创阶段,案例种子为空」),
* 组件按 hasAnyCases=false 渲染共创空态卡(cases-content-v3.tsx:305-343),
* 筛选器整段缺席是**当前契约**而非缺陷。
*
* 因此这里断言零案例下的确定性契约:
* - 页面骨架(hero/空态卡/底部 CTA)恒定渲染;
* - 行业筛选器组与案例卡片必须不存在(若种子加入案例而未更新本文件,此处变红提醒接线筛选断言);
* - 空态卡给出两条出口链接(/contact 与 /methodology),href 硬断言。
* 真实「点击筛选 → 列表收缩」断言需要 ≥1 条已发布案例研究才能接通,见整改报告。
*/
test('案例页:零案例种子下渲染共创空态且筛选器缺席', { tag: '@regression' }, async ({ page }) => {
await page.goto('/cases', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// 获取所有案例链接(排除导航、面包屑等非案例链接)
// 案例详情链接通常包含 /cases/ 后跟具体 slug,排除 /cases 本身
const allCaseLinks = page.locator('a[href*="/cases/"]').filter({
has: page.locator('h3, h2, .card-title, [class*="title"]'),
});
const initialCount = await allCaseLinks.count();
console.log('Initial case count:', initialCount);
// Hero 恒定渲染:h1 含「以成果为目标」(cases-content-v3.tsx:142-163)
await expect(page.locator('h1')).toContainText('以成果为目标');
// 如果筛选功能不可用(客户端筛选未实现),跳过验证
const filterButtons = page.locator('button[role="radio"]');
const filterCount = await filterButtons.count();
console.log('Filter buttons found:', filterCount);
// 零案例契约:筛选按钮组(role=group aria-label=按行业筛选)与案例卡片不得存在
await expect(
page.getByRole('group', { name: '按行业筛选' }),
).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
if (filterCount === 0) {
console.log('No filter buttons found, skipping filter test');
// 验证页面内容存在
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
return;
}
// 共创空态卡(hasAnyCases=false 分支)必须渲染,并带两条出口 CTA
const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]');
await expect(emptyPrimaryCta).toBeVisible();
await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact');
await expect(emptyPrimaryCta).toContainText('预约咨询');
// 验证默认显示所有案例
expect(initialCount).toBeGreaterThan(0);
const emptySecondaryCta = page.locator('[data-testid="cases-empty-secondary-cta"]');
await expect(emptySecondaryCta).toBeVisible();
await expect(emptySecondaryCta).toHaveAttribute('href', '/methodology');
await expect(emptySecondaryCta).toContainText('查看方法论');
// 尝试点击制造业筛选
const manufacturingFilter = page.locator('button[role="radio"]:has-text("制造业")').first();
if (await manufacturingFilter.isVisible()) {
await manufacturingFilter.click();
await page.waitForTimeout(1000);
// 空态标题 = EARLY_ACCESS.label(company.ts:133-136)
await expect(page.getByRole('heading', { level: 2, name: '首批客户共创中' })).toBeVisible();
// 获取筛选后的案例链接数量
const filteredLinks = page.locator('a[href*="/cases/"]').filter({
has: page.locator('h3, h2, .card-title, [class*="title"]'),
});
const filteredCount = await filteredLinks.count();
console.log('After manufacturing filter, case count:', filteredCount);
// 软验证:筛选后数量应减少或不变(如果筛选器是客户端行为)
expect(filteredCount).toBeLessThanOrEqual(initialCount);
}
// 切回全部
const allFilter = page.locator('button[role="radio"]:has-text("全部")').first();
if (await allFilter.isVisible()) {
await allFilter.click();
await page.waitForTimeout(1000);
const allLinks = page.locator('a[href*="/cases/"]').filter({
has: page.locator('h3, h2, .card-title, [class*="title"]'),
});
const allCount = await allLinks.count();
console.log('After reset to all, case count:', allCount);
expect(allCount).toBeGreaterThanOrEqual(initialCount - 1);
}
// 底部转化区恒定渲染(cases-content-v3.tsx:348-382)
const bottomPrimaryCta = page.locator('[data-testid="cases-bottom-primary-cta"]');
await expect(bottomPrimaryCta).toBeVisible();
await expect(bottomPrimaryCta).toHaveAttribute('href', '/contact');
});
+14 -2
View File
@@ -1,4 +1,4 @@
import { test, expect, APIRequestContext } from '@playwright/test';
import { test, expect, APIRequestContext } from './fixtures';
import { config as dotenvConfig } from 'dotenv';
import path from 'path';
@@ -263,6 +263,8 @@ test.describe('CMS 内容发布工作流', { tag: '@critical' }, () => {
test.describe.configure({ mode: 'serial' });
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
// N-27 修复:本块原先只有 helper 调用、块级零断言。helper 确有内部断言,
// 但块的终点状态与「前台可见」结果不在块级被证明 ⇒ 补两条块级硬断言。
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const token = await loginAdmin(request);
@@ -271,9 +273,14 @@ test.describe('CMS 内容发布工作流', { tag: '@critical' }, () => {
await submitForReview(request, token, id);
const afterApprove = await approveItem(request, token, id);
console.log('[DEBUG] after approve status=%s slug=%s', afterApprove.status, afterApprove.slug);
// 块级断言 1:工作流终态必须是 published(helper 只内部检查响应,
// 返回值再未被验证过)
expect(afterApprove.status, 'approve 后终态应为 published').toBe('published');
await revalidateNews(request, slug);
console.log('[DEBUG] revalidate done, navigating to /news/%s', slug);
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
// 块级断言 2:验证步骤确实抵达了前台详情 URL(而非停留在别处)
await expect(page).toHaveURL(new RegExp(`/news/${slug}$`));
});
test('非法状态流转会被拦截', async ({ request }) => {
@@ -332,11 +339,16 @@ test.describe('CMS 内容发布工作流', { tag: '@critical' }, () => {
expect(editorApproveResponse.status()).toBe(403);
// 4. 审核员审核通过
// N-27 修复:同 test 1 的口径——helper 内部断言响应,但块的终点状态与
// 「前台可见」结果必须在块级被证明。
const reviewerToken = await login(request, REVIEWER_CREDENTIALS);
await approveItem(request, reviewerToken, id);
const afterApprove = await approveItem(request, reviewerToken, id);
expect(afterApprove.status, '审核员 approve 后终态应为 published').toBe('published');
// 5. 刷新 ISR 缓存并验证前台可见
await revalidateNews(request, slug);
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
// 块级断言:验证步骤确实抵达了前台详情 URL(而非停留在别处)
await expect(page).toHaveURL(new RegExp(`/news/${slug}$`));
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test as base } from '@playwright/test';
export * from '@playwright/test';
// `next dev` 在文档外挂一个 <nextjs-portal>(devtools 指示器 + 报错浮层的宿主),生产构建里没有它。
// 其 shadow DOM 含 <footer class="error-overlay-footer">,而 Playwright 的 CSS 默认穿透 open shadow root,
// 于是 locator('footer') 解析到 2 个元素;它画成 38px 圆盘还是红色 "Issues" 胶囊又取决于当次 dev 会话的告警数。
// 本项目 dev 下 CSP 禁 eval,报错浮层一定挂得到——实测 devIndicators:false 也拿不掉(官方文档:关闭只隐藏指示器,
// compile/runtime 报错仍呈现)。它不属于产品内容,在 harness 层统一摘掉,让所有 project 只面对产品 DOM。
// 观察器挂在 document 而非 documentElement:官方文档写明 init script 在 documentElement 创建之前就已运行。
function stripDevChrome() {
const remove = () => {
document.querySelectorAll('nextjs-portal').forEach((node) => node.remove());
};
new MutationObserver(remove).observe(document, { childList: true, subtree: true });
remove();
}
export const test = base.extend({
// 第二个形参改名:Playwright 按位置传入 fixture 续接函数,若沿用 `use` 会被
// eslint 的 react-hooks/rules-of-hooks 误判成 React Hook 调用。
context: async ({ context }, continueWith) => {
await context.addInitScript(stripDevChrome);
await continueWith(context);
},
});
export default test;
+3 -5
View File
@@ -1,10 +1,9 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test.describe('Footer ICP/Police BeiAn Visibility', { tag: '@regression' }, () => {
test('desktop: ICP and police bei an should be visible', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500);
@@ -24,8 +23,7 @@ test.describe('Footer ICP/Police BeiAn Visibility', { tag: '@regression' }, () =
test('mobile: ICP and police bei an should be visible above tab bar', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500);
+224 -229
View File
@@ -1,265 +1,260 @@
import { test, expect } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* GA4 事件追踪验证测试套件
* GA4 事件追踪验证
*
* 通过 Playwright 网络拦截与页面评估,验证 Google Analytics 4 事件是否正确触发。
* 由于测试环境中的 NEXT_PUBLIC_GA_MEASUREMENT_ID 为空,应用的 analytics 模块
* 不会实际调用 gtag。本测试通过 addInitScript 注入 mock gtag 来记录调用,
* 并模拟用户交互后应触发的 analytics 调用来验证事件参数的正确性。
* 验收 A-6 重写:原实现先注入 mock window.gtag,再由测试自己调用 gtag 并断言
* 刚写入的 __gtagCalls —— 断言的是 fixture,不是应用。且 .env.production 配置了
* 真实 MEASUREMENT_ID 后,应用注入的 gtag.js 会覆盖 mock,导致全部用例失败。
*
* 现在只拦截 Google 的远端脚本(CI 不出网),gtag 函数与 dataLayer 队列由应用
* 自己的内联片段创建(src/components/analytics/GoogleAnalytics.tsx),
* 因此 window.dataLayer 中的每一条调用都必然由应用发出。
*
* 测试范围:
* 1. TC-GA4-001: 页面浏览追踪 - 首页加载时触发 gtag config
* 2. TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件
* 3. TC-GA4-003: CTA 按钮点击触发 button_click 事件
* 4. TC-GA4-004: 页面浏览追踪 - 产品页加载时触发 gtag config
* 1. TC-GA4-001: 首屏内联片段发出 js / consent default / 唯一一次 config
* 2. TC-GA4-002: 联系表单提交触发 form_submit + 单次 conversion
* 3. TC-GA4-003: 点击「接受所有」触发 button_click + consent update
* 4. TC-GA4-004: 客户端路由跳转触发带 page_path 的 config
*/
test.setTimeout(60000);
type GtagCall = unknown[];
type GtagParams = Record<string, unknown>;
/** 内联片段把参数按 arguments 对象压入队列,这里统一展开成数组便于断言。 */
async function gtagCalls(page: Page): Promise<GtagCall[]> {
return page.evaluate(() => {
const queue = (window as unknown as { dataLayer?: ArrayLike<unknown>[] }).dataLayer;
if (!queue) {return [];}
return Array.from(queue).map((entry) => Array.from(entry as ArrayLike<unknown>));
});
}
function callsWith(calls: GtagCall[], ...prefix: unknown[]): GtagCall[] {
return calls.filter((call) => prefix.every((value, index) => call[index] === value));
}
function params(call: GtagCall | undefined): GtagParams {
return (call?.[2] ?? {}) as GtagParams;
}
/** 拦截 Google 远端脚本,返回应用请求携带的 MEASUREMENT_ID;未配置 GA 时返回 null。 */
async function gotoWithGtag(page: Page, path: string): Promise<string | null> {
await page.route(/https?:\/\/www\.googletagmanager\.com\//, (route) =>
route.fulfill({
status: 200,
contentType: 'application/javascript',
body: '/* GA4 E2E stub: 保留应用自身的 dataLayer 队列 */',
})
);
const gtagRequest = page
.waitForRequest(/www\.googletagmanager\.com\/gtag\/js/, { timeout: 15000 })
.catch(() => null);
await page.goto(path, { timeout: 30000, waitUntil: 'domcontentloaded' });
const request = await gtagRequest;
if (!request) {return null;}
return new URL(request.url()).searchParams.get('id');
}
/** 预置同意偏好:抑制横幅遮挡,同时让应用发出 consent update。 */
async function seedConsent(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem(
'novalon-cookie-preferences',
JSON.stringify({
necessary: true,
analytics: true,
marketing: false,
functionality: true,
timestamp: Date.now(),
})
);
});
}
/**
* e2e/playwright.config.ts:29 的 storageState 全站预置了同意偏好(analytics:false),
* 因此横幅默认不出现;需要真实横幅的用例必须先清除该键。
*/
async function clearSeededConsent(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.removeItem('novalon-cookie-preferences');
});
}
async function expectGtagReady(page: Page): Promise<void> {
await expect
.poll(async () => (await gtagCalls(page)).length, { timeout: 15000 })
.toBeGreaterThan(0);
}
/** 等应用内联片段把 js / consent default / config 三条都写入队列后再断言,避免读到半成品。 */
async function expectSnippetCalls(page: Page): Promise<GtagCall[]> {
await expect
.poll(async () => {
const calls = await gtagCalls(page);
const present = [
callsWith(calls, 'js').length,
callsWith(calls, 'consent', 'default').length,
callsWith(calls, 'config').length,
];
return present.filter((count) => count > 0).length;
}, { timeout: 15000 })
.toBe(3);
return gtagCalls(page);
}
/** GoogleAnalytics 只在客户端路由变化时补发带 page_path 的 config。 */
async function routedPageViews(page: Page, measurementId: string | null): Promise<GtagCall[]> {
return callsWith(await gtagCalls(page), 'config', measurementId ?? '');
}
test.describe('GA4 事件追踪验证', { tag: ['@analytics', '@regression', '@critical'] }, () => {
test.describe.configure({ mode: 'parallel' });
// 在每个测试前设置 gtag mock
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
(window as any).gtag = (...args: any[]) => {
if (!(window as any).__gtagCalls) {
(window as any).__gtagCalls = [];
}
(window as any).__gtagCalls.push(args);
};
window.dataLayer = [];
});
});
test('TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config', async ({ page }) => {
await seedConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
/**
* Helper: 关闭 Cookie 同意横幅(如果存在)
*/
async function closeCookieConsent(page: any) {
const acceptAllBtn = page.locator('button', { hasText: '接受所有' });
if (await acceptAllBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await acceptAllBtn.click();
await page.waitForTimeout(500);
}
}
const calls = await expectSnippetCalls(page);
test('TC-GA4-001: 页面浏览追踪 - 首页加载时触发 gtag config', async ({ page }) => {
// 导航到首页
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
expect(callsWith(calls, 'js')).toHaveLength(1);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
const consentDefault = callsWith(calls, 'consent', 'default');
expect(consentDefault).toHaveLength(1);
expect(params(consentDefault[0]).analytics_storage).toBe('granted');
expect(params(consentDefault[0]).ad_storage).toBe('denied');
// 模拟首页加载时应触发的 gtag config 调用
await page.evaluate(() => {
(window as any).gtag('config', 'G-TEST123', {
page_path: '/',
page_title: document.title,
page_location: window.location.origin + '/',
});
});
// 验证 gtag config 调用
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const configCalls = gtagCalls.filter((call: any[]) => call[0] === 'config');
expect(configCalls.length).toBeGreaterThan(0);
expect(configCalls[0][1]).toBe('G-TEST123');
expect(configCalls[0][2]).toBeDefined();
expect(configCalls[0][2].page_path).toBe('/');
expect(configCalls[0][2].page_title).toBeTruthy();
const configs = callsWith(calls, 'config');
expect(configs).toHaveLength(1);
expect(configs[0]?.[1]).toBe(measurementId);
expect(params(configs[0]).anonymize_ip).toBe(true);
});
test('TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件', async ({ page }) => {
// 拦截 /api/contact 请求,模拟成功响应
await page.route('**/api/contact', async (route) => {
await route.fulfill({
await seedConsent(page);
await page.route('**/api/contact', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: 'true' }),
});
});
})
);
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const measurementId = await gotoWithGtag(page, '/contact');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
await expectGtagReady(page);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
await page.locator('[data-testid="name-input"]').first().fill('测试用户');
await page.locator('[data-testid="phone-input"]').first().fill('13800138000');
await page.locator('[data-testid="email-input"]').first().fill('test@example.com');
await page.locator('[data-testid="subject-input"]').first().fill('咨询产品方案');
await page
.locator('[data-testid="message-input"]')
.first()
.fill('您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。');
// 填写表单
const nameInput = page.locator('[data-testid="name-input"]').first();
await nameInput.fill('测试用户');
const phoneInput = page.locator('[data-testid="phone-input"]').first();
await phoneInput.fill('13800138000');
const emailInput = page.locator('[data-testid="email-input"]').first();
await emailInput.fill('test@example.com');
const subjectInput = page.locator('[data-testid="subject-input"]').first();
await subjectInput.fill('咨询产品方案');
const messageInput = page.locator('[data-testid="message-input"]').first();
await messageInput.fill('您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。');
// 提交表单
const submitButton = page.locator('[data-testid="submit-button"]').first();
await expect(submitButton).toBeEnabled({ timeout: 5000 });
await expect(submitButton).toBeEnabled({ timeout: 10000 });
await submitButton.click();
// 等待表单提交成功
await page.waitForTimeout(2000);
await expect
.poll(
async () => {
const calls = await gtagCalls(page);
return (
callsWith(calls, 'event', 'form_submit').length +
callsWith(calls, 'event', 'conversion').length
);
},
{ timeout: 15000 }
)
.toBeGreaterThanOrEqual(2);
// 验证表单提交成功(显示成功消息)
const successMessage = page.locator('text=提交成功').first();
const isSuccessVisible = await successMessage.isVisible({ timeout: 5000 }).catch(() => false);
if (isSuccessVisible) {
await expect(successMessage).toBeVisible();
const calls = await gtagCalls(page);
const formSubmit = callsWith(calls, 'event', 'form_submit');
expect(formSubmit).toHaveLength(1);
expect(params(formSubmit[0]).event_category).toBe('contact');
expect(params(formSubmit[0]).event_label).toBe('咨询产品方案');
expect(params(formSubmit[0]).event_value).toBe(1);
expect(params(formSubmit[0]).send_to).toBe(measurementId);
// contact-content-v3 只调用 trackContactForm(内部已发 conversion),
// 计数为 1 是「一次提交 = 一次转化」的回归护栏。
const conversions = callsWith(calls, 'event', 'conversion');
expect(conversions).toHaveLength(1);
expect(params(conversions[0]).conversion_label).toBe('contact_form_submit');
expect(params(conversions[0]).value).toBe(1);
expect(params(conversions[0]).currency).toBe('CNY');
expect(typeof params(conversions[0]).transaction_id).toBe('string');
});
test('TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件', async ({ page }) => {
await clearSeededConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
const acceptAll = page.locator('button', { hasText: '接受所有' });
await expect(acceptAll).toBeVisible({ timeout: 15000 });
await acceptAll.click();
await expect
.poll(
async () => callsWith(await gtagCalls(page), 'event', 'button_click').length,
{ timeout: 10000 }
)
.toBe(2);
const calls = await gtagCalls(page);
const buttonClick = callsWith(calls, 'event', 'button_click');
// handleAcceptAll 先复用 handleSavePreferences(save_cookie_preferences),再补发自身事件。
expect(buttonClick.map((call) => params(call).event_label)).toEqual([
'save_cookie_preferences',
'accept_all_cookies',
]);
for (const call of buttonClick) {
expect(params(call).event_category).toBe('engagement');
expect(params(call).send_to).toBe(measurementId);
}
// 模拟表单提交成功后应触发的 analytics 事件
// trackContactForm 内部会调用:
// - trackEvent('form_submit', 'contact', '咨询产品方案', 1)
// - trackConversion('contact_form_submit', 1)
await page.evaluate(() => {
// 模拟 form_submit 事件
(window as any).gtag('event', 'form_submit', {
event_category: 'contact',
event_label: '咨询产品方案',
event_value: 1,
send_to: 'G-TEST123',
});
const consentUpdate = callsWith(calls, 'consent', 'update');
expect(consentUpdate.length).toBeGreaterThan(0);
expect(params(consentUpdate[consentUpdate.length - 1]).analytics_storage).toBe('granted');
// 模拟 conversion 事件
(window as any).gtag('event', 'conversion', {
send_to: 'G-TEST123',
transaction_id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
value: 1,
currency: 'CNY',
conversion_label: 'contact_form_submit',
});
});
await expect(acceptAll).toBeHidden({ timeout: 10000 });
});
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
test('TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config', async ({ page }) => {
await seedConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
await expectGtagReady(page);
// 验证 form_submit 事件
const formSubmitCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'form_submit'
const privacyLink = page.locator('footer a[href="/privacy"]').first();
await expect(privacyLink).toBeVisible({ timeout: 15000 });
await privacyLink.click();
await expect(page).toHaveURL(/\/privacy$/);
await expect
.poll(async () => {
const views = await routedPageViews(page, measurementId);
return views.filter((call) => params(call).page_path === '/privacy').length;
}, { timeout: 15000 })
.toBe(1);
const routeConfig = (await routedPageViews(page, measurementId)).filter(
(call) => params(call).page_path === '/privacy'
);
expect(formSubmitCalls.length).toBeGreaterThan(0);
expect(formSubmitCalls[0][2].event_category).toBe('contact');
expect(formSubmitCalls[0][2].event_label).toBeTruthy();
// 验证 conversion 事件
const conversionCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'conversion'
);
expect(conversionCalls.length).toBeGreaterThan(0);
expect(conversionCalls[0][2].conversion_label).toBe('contact_form_submit');
expect(conversionCalls[0][2].value).toBe(1);
expect(conversionCalls[0][2].currency).toBe('CNY');
// A-17:SPA pageview 必须在下一帧读取标题,否则 page_title 会是空串
// (由 GoogleAnalytics.test.tsx「read document.title after metadata paints」钉住)。
expect(String(params(routeConfig[0]).page_title)).toContain('隐私政策');
expect(params(routeConfig[0]).page_location).toContain('/privacy');
});
test('TC-GA4-003: CTA 按钮点击触发 button_click 事件', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
// 滚动到页面底部以触发 CTA 区域
await page.evaluate(() => {
const ctaSection = document.querySelector('#cta');
if (ctaSection) {
ctaSection.scrollIntoView({ behavior: 'instant', block: 'center' });
}
});
await page.waitForTimeout(500);
// 查找 CTA 按钮("预约免费咨询")
const ctaButton = page.locator('#cta a', { hasText: '预约免费咨询' }).first();
const isCtaVisible = await ctaButton.isVisible({ timeout: 5000 }).catch(() => false);
if (isCtaVisible) {
// 模拟点击 CTA 按钮后应触发的 button_click 事件
// 使用 button_click 事件格式: trackButtonClick 调用 trackEvent('button_click', 'engagement', buttonName)
await page.evaluate(() => {
(window as any).gtag('event', 'button_click', {
event_category: 'engagement',
event_label: '预约免费咨询',
send_to: 'G-TEST123',
});
});
// 验证 button_click 事件
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const buttonClickCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'button_click'
);
expect(buttonClickCalls.length).toBeGreaterThan(0);
expect(buttonClickCalls[0][2].event_category).toBe('engagement');
expect(buttonClickCalls[0][2].event_label).toBe('预约免费咨询');
} else {
// CTA 按钮不可见时,验证其他导航按钮的点击事件
const navButton = page.locator('nav a, header a', { hasText: '联系我们' }).first();
const isNavVisible = await navButton.isVisible({ timeout: 3000 }).catch(() => false);
if (isNavVisible) {
await page.evaluate(() => {
(window as any).gtag('event', 'button_click', {
event_category: 'navigation',
event_label: '联系我们',
send_to: 'G-TEST123',
});
});
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const buttonClickCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'button_click'
);
expect(buttonClickCalls.length).toBeGreaterThan(0);
expect(buttonClickCalls[0][2].event_category).toBe('navigation');
expect(buttonClickCalls[0][2].event_label).toBe('联系我们');
} else {
// 降级验证:至少 verify gtag 函数可用并可记录事件
const gtagFunctionExists = await page.evaluate(() => typeof (window as any).gtag === 'function');
expect(gtagFunctionExists).toBe(true);
}
}
});
test('TC-GA4-004: 页面浏览追踪 - 产品页加载时触发 gtag config', async ({ page }) => {
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
// 验证页面标题
const pageTitle = page.locator('h1').first();
await expect(pageTitle).toBeVisible({ timeout: 10000 });
// 模拟产品页加载时应触发的 gtag config 调用
await page.evaluate(() => {
(window as any).gtag('config', 'G-TEST123', {
page_path: '/products',
page_title: document.title,
page_location: window.location.origin + '/products',
});
});
// 验证 gtag config 调用
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const configCalls = gtagCalls.filter((call: any[]) => call[0] === 'config');
expect(configCalls.length).toBeGreaterThan(0);
expect(configCalls[0][1]).toBe('G-TEST123');
expect(configCalls[0][2]).toBeDefined();
expect(configCalls[0][2].page_path).toBe('/products');
expect(configCalls[0][2].page_title).toBeTruthy();
});
});
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, type Locator } from '@playwright/test';
/**
* 等到 React 把 props/事件监听挂到目标元素上,再对它发起交互。
*
* 为什么需要:SSR HTML 一进入 DOM,元素就已经「可见」,但客户端组件的 onClick/onMouseEnter
* 要等 hydration 完成才存在。这段窗口内派发的指针事件被浏览器当作普通事件丢弃,hydration 后
* 也不会重放 —— 表现为 hover/click 调用成功、状态却永远不变,然后 expect 轮询到 15s timeout。
*
* 实测依据(next dev,本机 :3000):/contact 的 [data-testid="submit-button"] 在 first visible
* 之后 1735ms 才挂上 handler;早点击得到 0 条 [data-testid="error-message"],
* hydration 后同一动作得到 5 条。主导航「产品」按钮 first visible 时同样尚未 hydration。
*
* 探针取 React DOM 写在宿主元素上的 __reactProps$ 内部键:`toBeVisible()` 只能证明
* 「渲染出来了」,证明不了「能交互」,而这个键是唯一能把两者分开的信号。项目锁定 React 18.3
* (package.json),该前缀自 React 16 起稳定。
*/
export async function expectHydrated(target: Locator, timeout = 20000): Promise<void> {
await expect
.poll(
async () =>
target.evaluate((el) => Object.keys(el).some((key) => key.startsWith('__reactProps$'))),
{ timeout, message: '目标元素尚未完成 hydration' },
)
.toBe(true);
}
+45 -58
View File
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { test, expect, devices } from './fixtures';
import AxeBuilder from '@axe-core/playwright';
import { scanTouchTargets, formatTargets } from './touch-targets';
/**
* 移动端可访问性专项测试
@@ -17,6 +18,11 @@ import AxeBuilder from '@axe-core/playwright';
test.setTimeout(60000);
// 「移动端」在本文件名与 @mobile 标签里只是意图,viewport 实际由 project 决定:
// 同一份断言在桌面 project 量到的是桌面布局(实测 17 处小目标 vs 移动 11 处)。
// 与 mobile.spec.ts 保持同一约定,钉 iPhone 14 视口,四个引擎才是在量同一条移动布局。
test.use({ ...devices['iPhone 14'] });
// ==================== 1. axe-core 全量扫描 ====================
test.describe('移动端可访问性 - axe-core 合规扫描', { tag: '@mobile @accessibility' }, () => {
const pages = [
@@ -67,52 +73,23 @@ test.describe('移动端可访问性 - axe-core 合规扫描', { tag: '@mobile @
// ==================== 2. 触摸目标尺寸检查 ====================
test.describe('移动端可访问性 - 触摸目标尺寸', { tag: '@mobile @accessibility' }, () => {
test('首页交互元素触摸目标 ≥ 44px', async ({ page }) => {
test('首页交互元素触摸目标:AA 2.5.8 ≥ 24px 硬门禁', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(300);
// 检查可见区域内的交互元素
const smallTargets = await page.evaluate(() => {
const interactiveElements = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
);
const issues: Array<{ tag: string; text: string; width: number; height: number }> = [];
const { aaViolations, aaaShortlist } = await scanTouchTargets(page);
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// 只检查可见元素
if (rect.width > 0 && rect.height > 0 && rect.width < 1000) {
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
// 排除内联文本链接(段落中的 a 标签)
const isInline = el.closest('p, span, li, h1, h2, h3, h4, h5, h6');
if (!isInline) {
issues.push({
tag: el.tagName,
text: text,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
}
});
return issues;
});
if (smallTargets.length > 0) {
console.log(`触摸目标不足 44px 的元素 (${smallTargets.length} 个):`);
smallTargets.slice(0, 10).forEach(t => {
console.log(` <${t.tag}> "${t.text}" — ${t.width}x${t.height}px`);
});
if (aaaShortlist.length > 0) {
console.log(`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`);
}
// 允许少量例外(如小图标装饰按钮),但不应超过 5 个
expect(smallTargets.length).toBeLessThanOrEqual(5);
expect(
aaViolations.length,
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`
).toBe(0);
});
});
@@ -190,27 +167,37 @@ test.describe('移动端可访问性 - 图片 Alt 文本', { tag: '@mobile @acce
// ==================== 6. 颜色对比度 ====================
test.describe('移动端可访问性 - 颜色对比度', { tag: '@mobile @accessibility' }, () => {
test('无白色文字在白色背景上的问题', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const contrastIssues = await page.evaluate(() => {
const issues: string[] = [];
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, a, li, td, th');
textElements.forEach(el => {
const style = window.getComputedStyle(el);
const color = style.color;
const bgColor = style.backgroundColor;
if (color === 'rgb(255, 255, 255)' && bgColor === 'rgb(255, 255, 255)') {
issues.push(`${el.tagName}: 白色文字在白色背景上`);
}
});
return issues;
// 验收 A-12:Cookie 同意条的「隐私政策」链接曾以 3.31:1 通过全站扫描 —— 因为 storageState.json
// 预置了 novalon-cookie-preferences,同意条在测试里根本不渲染,正文扫描永远看不见它。
// 这里显式清掉偏好、等条出现后再扫,且按 axe 的**节点数**归零(不按 impact 过滤),
// 否则「只拦 critical/serious」的旧口径会让同一条失败再次漏网。
test('Cookie 同意条可见时 axe color-contrast 违规节点为 0', async ({ page }) => {
await page.addInitScript(() => {
localStorage.removeItem('novalon-cookie-preferences');
localStorage.removeItem('ga_consent');
});
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0);
const banner = page.locator('[data-testid="cookie-consent-banner"]');
await expect(banner, 'Cookie 同意条未出现(2s 定时器或偏好清除失效),本用例失去意义')
.toBeVisible({ timeout: 10000 });
// 同意条内的隐私政策链接才是 A-12 的现场;页面别处(footer)也有 /privacy 链接,
// 只按 href 定位会永远命中 footer,从而「通过」一个根本没被扫到的目标。
await expect(banner.locator('a[href="/privacy"]')).toBeVisible();
await page.waitForTimeout(600);
const results = await new AxeBuilder({ page })
.include('[data-testid="cookie-consent-banner"]')
.withRules(['color-contrast'])
.analyze();
const nodes = results.violations.flatMap((v) => v.nodes);
if (nodes.length > 0) {
console.log('color-contrast 违规节点:\n' + nodes.map((n) => `${n.impact} ${n.target.join(' ')} — ${n.failureSummary?.slice(0, 120)}`).join('\n'));
}
expect(nodes.length, `axe color-contrast 违规节点应为 0,实际 ${nodes.length}`).toBe(0);
// 放行条件⑤要求「节点计数 = 0」本身留痕,而不只是「用例通过」这一代理信号。
console.log(`[A-12 证据] axe color-contrast 在 cookie-consent-banner 范围内的违规节点数 = ${nodes.length}`);
});
});
+229 -183
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { test, expect, devices } from "./fixtures";
import { scanTouchTargets, formatTargets } from "./touch-targets";
/**
* 移动端性能基线测试
@@ -20,187 +21,254 @@ import { test, expect } from '@playwright/test';
test.setTimeout(90000);
// 文件头声明的是「iPhone 14 视口(390x844)」,但 viewport 此前由 project 决定 ——
// 桌面 project 量到的是桌面布局。显式钉住设备,指标与触摸目标才对应同一条移动布局。
test.use({ ...devices["iPhone 14"] });
// ==================== 1. 核心性能指标 ====================
test.describe('移动端性能 - 核心加载指标', { tag: '@mobile @performance' }, () => {
test('首页 FCP < 2s', async ({ page }) => {
const fcp = await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }).then(async () => {
await page.waitForTimeout(1500);
return page.evaluate(() => {
const entries = performance.getEntriesByType('paint');
const fcpEntry = entries.find(e => e.name === 'first-contentful-paint');
return fcpEntry ? fcpEntry.startTime : null;
});
test.describe(
"移动端性能 - 核心加载指标",
{ tag: "@mobile @performance" },
() => {
// 默认口径的 webServer 是 npm run dev:某路由首次访问要现场编译,编译耗时会计进 paint / navigation 计时
// (chain4 实测首页 LCP 2736ms 破 2500ms 线)。先预热一次,量到的才是页面本身。
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
});
if (fcp !== null) {
test("首页 FCP < 2s", async ({ page }) => {
// N-27 修复:原 `if (fcp !== null)` 守卫 ⇒ FCP 永不出现(白屏/脚本崩溃)时
// 本例静默通过。改为轮询直到 paint 条目出现,超时即红;出现后做硬阈值断言。
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const readFcp = () =>
page.evaluate(() => {
const entries = performance.getEntriesByType("paint");
const fcpEntry = entries.find(
(e) => e.name === "first-contentful-paint",
);
return fcpEntry ? fcpEntry.startTime : null;
});
await expect
.poll(readFcp, { timeout: 15000, message: "FCP 在 15s 内未出现" })
.not.toBeNull();
const fcp = await readFcp();
if (fcp === null) {
throw new Error("FCP 在轮询通过后仍为 null(paint 条目在读取瞬间消失)");
}
console.log(`Mobile FCP: ${fcp.toFixed(0)}ms`);
expect(fcp).toBeLessThan(2000);
}
});
test('首页 LCP < 2.5s', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const lcp = await page.evaluate(() => {
return new Promise<number | null>((resolve) => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.startTime);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// 超时返回 null
setTimeout(() => resolve(null), 5000);
});
});
if (lcp !== null) {
test("首页 LCP < 2.5s", async ({ page }) => {
// N-27 修复:原 `if (lcp !== null)` 守卫 ⇒ 观测不到 LCP 时静默通过。
// 改为轮询直到拿到非 null 的 LCP(每轮内部 5s 超时返回 null 后由 poll 重试),
// 15s 仍取不到即红。若某浏览器引擎不支持 LCP 条目,本例将红——这是刻意收紧,
// 已在整改报告中列为待运行时验证项。
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const readLcp = () =>
page.evaluate(() => {
return new Promise<number | null>((resolve) => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.startTime);
}
});
observer.observe({
type: "largest-contentful-paint",
buffered: true,
});
// 单轮超时返回 null,由 expect.poll 继续重试
setTimeout(() => resolve(null), 5000);
});
});
await expect
.poll(readLcp, { timeout: 15000, message: "LCP 在 15s 内未采到" })
.not.toBeNull();
const lcp = await readLcp();
if (lcp === null) {
throw new Error("LCP 在轮询通过后仍为 null(性能条目在读取瞬间消失)");
}
console.log(`Mobile LCP: ${lcp.toFixed(0)}ms`);
expect(lcp).toBeLessThan(2500);
}
});
test('首页 DOMContentLoaded < 3s', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
const loadTime = Date.now() - startTime;
console.log(`Mobile DOMContentLoaded: ${loadTime}ms`);
expect(loadTime).toBeLessThan(3000);
});
test('首页完整加载时间 < 8s', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(1000);
const loadTime = Date.now() - startTime;
console.log(`Mobile full load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(8000);
});
test('长任务计数 ≤ 3', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const longTaskCount = await page.evaluate(() => {
return new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
resolve(list.getEntries().length);
});
observer.observe({ entryTypes: ['longtask'] });
setTimeout(() => resolve(0), 3000);
});
});
console.log(`Mobile long task count: ${longTaskCount}`);
expect(longTaskCount).toBeLessThanOrEqual(3);
});
});
test("首页 DOMContentLoaded < 3s", async ({ page }) => {
const startTime = Date.now();
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const loadTime = Date.now() - startTime;
console.log(`Mobile DOMContentLoaded: ${loadTime}ms`);
expect(loadTime).toBeLessThan(3000);
});
test("首页完整加载时间 < 8s", async ({ page }) => {
const startTime = Date.now();
await page.goto("/", { waitUntil: "load", timeout: 30000 });
await page.waitForTimeout(1000);
const loadTime = Date.now() - startTime;
console.log(`Mobile full load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(8000);
});
test("长任务计数 ≤ 3", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
const longTaskCount = await page.evaluate(() => {
return new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
resolve(list.getEntries().length);
});
observer.observe({ entryTypes: ["longtask"] });
setTimeout(() => resolve(0), 3000);
});
});
console.log(`Mobile long task count: ${longTaskCount}`);
expect(longTaskCount).toBeLessThanOrEqual(3);
});
},
);
// ==================== 2. 关键页面加载对比 ====================
test.describe('移动端性能 - 关键页面加载对比', { tag: '@mobile @performance' }, () => {
const pages = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' },
{ path: '/products', name: '产品中心' },
{ path: '/solutions', name: '解决方案' },
{ path: '/services', name: '服务' },
{ path: '/news', name: '新闻' },
{ path: '/cases', name: '案例' },
];
test.describe(
"移动端性能 - 关键页面加载对比",
{ tag: "@mobile @performance" },
() => {
const pages = [
{ path: "/", name: "首页" },
{ path: "/about", name: "关于我们" },
{ path: "/contact", name: "联系我们" },
{ path: "/products", name: "产品中心" },
{ path: "/solutions", name: "解决方案" },
{ path: "/services", name: "服务" },
{ path: "/news", name: "新闻" },
{ path: "/cases", name: "案例" },
];
for (const { path, name } of pages) {
test(`${name} (${path}) 加载 < 5s`, async ({ page }) => {
const startTime = Date.now();
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const loadTime = Date.now() - startTime;
console.log(`Mobile ${name} load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
});
}
});
for (const { path, name } of pages) {
test(`${name} (${path}) 加载 < 5s`, async ({ page }) => {
// 同核心指标块:预热排除 dev 现场编译,且把水合等待移出计时区间,
// 否则 5000ms 预算里有 1500ms 是固定的 settle 等待(chain3/chain4 同类破线根因)。
await page.goto(path, { waitUntil: "commit", timeout: 30000 });
const startTime = Date.now();
await page.goto(path, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
const loadTime = Date.now() - startTime;
await page.waitForTimeout(1500);
console.log(`Mobile ${name} load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
});
}
},
);
// ==================== 3. 资源加载分析 ====================
test.describe('移动端性能 - 资源加载分析', { tag: '@mobile @performance' }, () => {
test('图片资源大小合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
test.describe(
"移动端性能 - 资源加载分析",
{ tag: "@mobile @performance" },
() => {
test("图片资源大小合理", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
const imageAnalysis = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
let largeImages = 0;
let oversizedCount = 0;
const imageAnalysis = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll("img"));
let largeImages = 0;
let oversizedCount = 0;
images.forEach(img => {
if (img.naturalWidth > 1920) {
largeImages++;
}
// 检查移动端是否加载了远大于视口的图片
if (img.naturalWidth > 800 && img.naturalWidth > 0) {
oversizedCount++;
}
images.forEach((img) => {
if (img.naturalWidth > 1920) {
largeImages++;
}
// 检查移动端是否加载了远大于视口的图片
if (img.naturalWidth > 800 && img.naturalWidth > 0) {
oversizedCount++;
}
});
return {
totalImages: images.length,
largeImageCount: largeImages,
oversizedForMobile: oversizedCount,
};
});
return {
totalImages: images.length,
largeImageCount: largeImages,
oversizedForMobile: oversizedCount,
};
console.log("Mobile image analysis:", imageAnalysis);
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
// N-33:largeImageCount / oversizedForMobile 原本算完只 log、从不断言(等于白算)。
// 阈值不是拍脑袋:在装配后的 standalone 产物上以 375px 视口实测 /、/about、/products、
// /cases、/news 五页,naturalWidth > 1920 的图片数为 0(首页最大 480,/cases 与 /news 最大 1108),
// 故按「不得引入超过 1920 宽的图源」设棘轮;>800 的张数在 /cases 与 /news 上为 1~2,属设计现状,
// 因此只作可观测输出、不设阈值(硬设会误伤真实图片)。
expect(imageAnalysis.largeImageCount).toBe(0);
});
console.log('Mobile image analysis:', imageAnalysis);
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
});
test("JS/CSS 资源加载时间合理", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
test('JS/CSS 资源加载时间合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
const resourceTiming = await page.evaluate(() => {
const entries = performance.getEntriesByType(
"resource",
) as PerformanceResourceTiming[];
return entries
.filter(
(e) =>
e.initiatorType === "script" || e.initiatorType === "stylesheet",
)
.map((e) => ({
name: e.name.split("/").pop(),
type: e.initiatorType,
duration: Math.round(e.duration),
transferSize: e.transferSize,
}));
});
const resourceTiming = await page.evaluate(() => {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return entries
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'stylesheet')
.map(e => ({
name: e.name.split('/').pop(),
type: e.initiatorType,
duration: Math.round(e.duration),
transferSize: e.transferSize,
}));
console.log("Mobile resource timing:", resourceTiming.slice(0, 10));
const criticalResources = resourceTiming.filter(
(r) =>
r.name?.includes("main") ||
r.name?.includes("chunk") ||
r.name?.includes("app"),
);
for (const resource of criticalResources) {
expect(resource.duration).toBeLessThan(3000);
}
});
console.log('Mobile resource timing:', resourceTiming.slice(0, 10));
const criticalResources = resourceTiming.filter(r =>
r.name?.includes('main') ||
r.name?.includes('chunk') ||
r.name?.includes('app')
);
for (const resource of criticalResources) {
expect(resource.duration).toBeLessThan(3000);
}
});
});
},
);
// ==================== 4. 移动端渲染性能 ====================
test.describe('移动端性能 - 渲染性能', { tag: '@mobile @performance' }, () => {
test('页面无水平滚动条', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test.describe("移动端性能 - 渲染性能", { tag: "@mobile @performance" }, () => {
test("页面无水平滚动条", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);
const hasHorizontalScroll = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
return (
document.documentElement.scrollWidth >
document.documentElement.clientWidth
);
});
expect(hasHorizontalScroll).toBeFalsy();
});
test('滚动流畅无卡顿', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("滚动流畅无卡顿", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);
// 模拟快速滚动
@@ -210,47 +278,25 @@ test.describe('移动端性能 - 渲染性能', { tag: '@mobile @performance' },
}
// 滚动后页面不应崩溃
const bodyVisible = await page.locator('body').isVisible();
const bodyVisible = await page.locator("body").isVisible();
expect(bodyVisible).toBeTruthy();
});
test('触摸目标尺寸 ≥ 44px(WCAG 2.1 AA)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("触摸目标尺寸:AA 2.5.8 ≥ 24px 硬门禁", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);
const smallTargets = await page.evaluate(() => {
const interactiveElements = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
const { aaViolations, aaaShortlist } = await scanTouchTargets(page);
if (aaaShortlist.length > 0) {
console.log(
`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`,
);
const issues: Array<{ tag: string; text: string; width: number; height: number }> = [];
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// 只检查可见且非零尺寸的元素
if (rect.width > 0 && rect.height > 0 && rect.width < 1000) {
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
// 排除内联文本链接(如段落中的 a 标签)
const isInline = el.closest('p, span, li, h1, h2, h3, h4, h5, h6');
if (!isInline) {
issues.push({
tag: el.tagName,
text: text,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
}
});
return issues;
});
if (smallTargets.length > 0) {
console.log('Small touch targets found:', smallTargets.slice(0, 10));
}
// 允许少量例外(如小图标按钮),但不应超过 5 个
expect(smallTargets.length).toBeLessThanOrEqual(5);
expect(
aaViolations.length,
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`,
).toBe(0);
});
});
});
+103 -139
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* 移动端用户旅程 E2E 测试
@@ -270,6 +270,8 @@ test.describe('UJ-02 Mobile: 行业客户浏览解决方案到产品的旅程',
// ==================== UJ-04 Mobile: 新闻读者浏览旅程 ====================
test.describe('UJ-04 Mobile: 新闻读者浏览旅程(列表 → 详情 → 返回)', () => {
test('@mobile @journey @regression 读者从新闻列表浏览到详情页并返回', async ({ page }) => {
// N-27 修复:同桌面版 UJ-04(user-journey.spec.ts)——原 `if (articleCount > 0)`
// 包住整段旅程,新闻消失时静默通过。种子恰有 2 篇已发布新闻,改为全程硬断言。
// === Step 1: 访问新闻列表 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
@@ -280,199 +282,161 @@ test.describe('UJ-04 Mobile: 新闻读者浏览旅程(列表 → 详情 →
await expect(newsTitle).toBeVisible({ timeout: 10000 });
// 验证新闻列表存在文章条目
const articleLinks = page.locator('a[href*="/news/"]');
const articleCount = await articleLinks.count();
console.log('UJ-04 Mobile article links found:', articleCount);
const firstArticle = page.locator('a[href*="/news/"]').first();
await expect(firstArticle).toBeVisible({ timeout: 5000 });
if (articleCount > 0) {
// === Step 2: 点击第一篇新闻进入详情页 ===
const firstArticle = articleLinks.first();
await firstArticle.click();
// === Step 2: 点击第一篇新闻进入详情页 ===
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 4: 通过面包屑返回新闻列表 ===
// news-detail-content-v3.tsx:68-71 恒渲染 breadcrumb nav 中的「新闻动态」链接
const breadcrumbNewsLink = page.locator('nav[aria-label="breadcrumb"] a[href="/news"]');
await expect(breadcrumbNewsLink).toBeVisible();
await Promise.all([
page.waitForURL(/\/news\/?$/, { timeout: 15000, waitUntil: 'commit' }),
breadcrumbNewsLink.click(),
]);
// === Step 4: 返回新闻列表 ===
const breadcrumbNewsLink = page.locator('nav a[href="/news"], a:has-text("新闻")').first();
if (await breadcrumbNewsLink.count() > 0 && await breadcrumbNewsLink.isVisible()) {
await breadcrumbNewsLink.click();
await page.waitForURL(/\/news\/?$/, { timeout: 10000 });
} else {
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
}
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/news\/?$/);
} else {
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/news\/?$/);
});
});
// ==================== UJ-05 Mobile: 案例浏览者筛选旅程 ====================
test.describe('UJ-05 Mobile: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@mobile @journey @regression 客户从案例列表筛选并查看详情', async ({ page }) => {
// === Step 1: 访问案例列表 ===
test('@mobile @journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => {
// N-27 修复:同桌面版 UJ-05。种子刻意 0 案例(prisma/seed.ts:628-631),
// 原 `if (caseLinkCount > 0)` + else 兜底 ⇒ 案例区为空也绿。
// 现断言当前真实契约:空态卡存在、筛选器缺席,并走完空态转化出口到联系页。
await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
const casesTitle = page.locator('h1').first();
await expect(casesTitle).toBeVisible({ timeout: 10000 });
console.log('UJ-05 Mobile cases page loaded');
await expect(casesTitle).toContainText('以成果为目标');
// === Step 2: 尝试使用行业筛选 ===
const filterButtons = page.locator(
'button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), ' +
'button:has-text("科技")'
);
const filterCount = await filterButtons.count();
if (filterCount > 0) {
const firstFilter = filterButtons.first();
await firstFilter.click();
await page.waitForTimeout(1500);
}
// 零案例契约:行业筛选按钮组与案例卡片必须不存在
await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
// === Step 3: 点击案例进入详情页 ===
const caseLinks = page.locator('a[href*="/cases/"]');
const caseLinkCount = await caseLinks.count();
console.log('UJ-05 Mobile case links found:', caseLinkCount);
// 空态转化卡:预约咨询 CTA 恒定渲染(cases-content-v3.tsx:334-336)
const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]');
await expect(emptyPrimaryCta).toBeVisible();
await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact');
if (caseLinkCount > 0) {
const firstCase = caseLinks.first();
await firstCase.click();
// === 点击转化出口,验证抵达联系页 ===
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/contact', { timeout: 15000, waitUntil: 'commit' }),
emptyPrimaryCta.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/contact');
await page.waitForURL(/\/cases\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 4: 验证案例详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
const hasCaseIndicator = detailText!.includes('案例') ||
detailText!.includes('成果') ||
detailText!.includes('挑战') ||
detailText!.includes('解决方案');
expect(hasCaseIndicator).toBe(true);
} else {
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
const contactTitle = page.locator('h1').first();
await expect(contactTitle).toBeVisible({ timeout: 10000 });
const contactText = await page.locator('main').first().textContent();
expect(contactText!.length).toBeGreaterThan(50);
});
});
// ==================== UJ-10 Mobile: 深度搜索者旅程 ====================
test.describe('UJ-10 Mobile: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@mobile @journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// === Step 1: 访问新闻列表 ===
// N-27 修复:同桌面版 UJ-10(user-journey.spec.ts)——原 Step 2/3/4/5/6 全部被
// `if (isVisible)`/`if (count > 0)` 包住。分类按钮组与 2 篇种子新闻(各属一类)
// 均为恒定契约 ⇒ 全程硬断言。
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
const newsTitle = page.locator('h1').first();
await expect(newsTitle).toBeVisible({ timeout: 10000 });
console.log('UJ-10 Mobile news page loaded');
// 验证分类筛选按钮存在
const categoryButtons = page.locator('button:has-text("公司新闻"), button:has-text("研发动态")');
const categoryCount = await categoryButtons.count();
expect(categoryCount).toBeGreaterThan(0);
// 验证分类筛选按钮存在且可点(news-content-v3.tsx:159-167 无条件渲染)
const companyNewsBtn = page.locator('button', { hasText: '公司新闻' }).first();
const devNewsBtn = page.locator('button', { hasText: '研发动态' }).first();
await expect(companyNewsBtn).toBeVisible();
await expect(devNewsBtn).toBeVisible();
// === Step 2: 点击"公司新闻"分类筛选 ===
const companyNewsBtn = page.locator('button:has-text("公司新闻")').first();
if (await companyNewsBtn.isVisible()) {
await companyNewsBtn.click();
await page.waitForTimeout(1000);
}
// === Step 2: 点击"公司新闻"分类筛选 → 恰收窄到 1 篇 ===
await companyNewsBtn.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
if (await firstArticle.count() > 0 && await firstArticle.isVisible()) {
await firstArticle.click();
await expect(firstArticle).toContainText('正式成立');
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toContainText('正式成立');
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证内容发现:相关新闻或推荐阅读区域
const relatedSection = page.locator(
'text=/相关新闻|推荐阅读|相关文章|RELATED|more news/i'
).first();
if (await relatedSection.count() > 0) {
console.log('UJ-10 Mobile related news section found');
}
}
// 两篇新闻分属不同分类 ⇒ 同分类相关新闻恒为空,区域必须缺席
await expect(page.getByRole('heading', { name: '相关新闻' })).toHaveCount(0);
// === Step 4: 返回新闻列表,切换分类 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 切换到"研发动态"分类
const devNewsBtn = page.locator('button:has-text("研发动态")').first();
if (await devNewsBtn.isVisible()) {
await devNewsBtn.click();
await page.waitForTimeout(1000);
}
const devNewsBtn2 = page.locator('button', { hasText: '研发动态' }).first();
await expect(devNewsBtn2).toBeVisible();
await devNewsBtn2.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 5: 阅读第二篇新闻文章 ===
const secondArticle = page.locator('a[href*="/news/"]').first();
if (await secondArticle.count() > 0 && await secondArticle.isVisible()) {
await secondArticle.click();
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
secondArticle.click(),
]);
await page.waitForTimeout(1000);
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toBeVisible({ timeout: 10000 });
const secondDetailTitleText = await secondDetailTitle.textContent();
expect(secondDetailTitleText).toBeTruthy();
expect(secondDetailTitleText!.length).toBeGreaterThan(0);
}
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toContainText('数字化转型');
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
// === Step 6: 返回全部新闻列表 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const allBtn = page.locator('button:has-text("全部")').first();
if (await allBtn.isVisible()) {
await allBtn.click();
await page.waitForTimeout(1000);
}
const allBtn = page.locator('button', { hasText: '全部' }).first();
await expect(allBtn).toBeVisible();
await allBtn.click();
const allArticles = page.locator('a[href*="/news/"]');
const allCount = await allArticles.count();
expect(allCount).toBeGreaterThanOrEqual(1);
console.log('UJ-10 Mobile all articles count:', allCount);
// 全部种子新闻恰好 2 篇
await expect(page.locator('a[href*="/news/"]')).toHaveCount(2);
// 最终验证
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 Mobile completed successfully');
// 最终验证:搜索发现能力('数字化转型' 恰命中 1 篇)
const searchInput = page.getByLabel('搜索新闻');
await expect(searchInput).toBeVisible();
await searchInput.fill('数字化转型');
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
});
});
+13 -12
View File
@@ -1,4 +1,5 @@
import { test, expect, devices } from '@playwright/test';
import { test, expect, devices } from './fixtures';
import { pollBoundingBox } from './assert-helpers';
/**
* 移动端专项 E2E 测试套件
@@ -261,13 +262,11 @@ test.describe('移动端产品浏览 - 产品中心 @mobile @regression', () =>
const card = productCards.nth(i);
await expect(card).toBeVisible();
// 检查卡片边界在视口内
const box = await card.boundingBox();
expect(box).not.toBeNull();
if (box) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(390); // iPhone 14 宽度
}
// 检查卡片边界在视口内(N-27 修复:原 `if (box)` 守卫会在盒模型读取失败时
// 静默跳过边界断言 ⇒ 改用 pollBoundingBox,超时/消失即红)
const box = await pollBoundingBox(card);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(390); // iPhone 14 宽度(test.use 固定 390 视口)
}
});
@@ -330,10 +329,12 @@ test.describe('移动端产品浏览 - 产品中心 @mobile @regression', () =>
expect(contentText!.length).toBeGreaterThan(100);
// 移动端详情页应包含返回操作
const backLink = page.locator('a[href="/products"], a:has-text("返回")').first();
if (await backLink.count() > 0) {
await expect(backLink).toBeVisible();
}
// N-27 修复:原 `if (count > 0)` 守卫 ⇒ 返回链接消失时静默通过。
// 证据:product-detail-content-v3.tsx:97 恒定渲染「查看更多产品」CTA
//(data-testid="product-detail-hero-secondary",href="/products")。
const backLink = page.locator('[data-testid="product-detail-hero-secondary"]');
await expect(backLink).toBeVisible();
await expect(backLink).toHaveAttribute('href', '/products');
});
test('移动端产品详情页可返回列表', async ({ page }) => {
+22 -7
View File
@@ -1,29 +1,44 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
import { expectHydrated } from './hydrated';
// 桌面 hover 下拉在 md 断点以上才渲染(移动端用抽屉 + TabBar),
// 固定为桌面视口,使 chromium-mobile 项目也按本用例真实语义验证引擎差异。
test.use({ viewport: { width: 1440, height: 900 } });
test('主导航产品下拉菜单可在 hover 时展开', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
// 等待桌面导航 hydration 完成
await expect(page.locator('nav[aria-label="主导航"]')).toBeVisible({ timeout: 10000 });
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 expectHydrated(productsButton);
// Header 入场动画 + 并发负载下,一次 hover 可能落在指针尚未真正覆盖按钮的那一帧,
// mouseenter 不会补发;重新把指针移开再悬停,直到面板真的展开。
await expect(async () => {
await page.mouse.move(0, 0);
await productsButton.hover();
await expect(productsButton).toHaveAttribute('aria-expanded', 'true', { timeout: 1000 });
}).toPass({ timeout: 20000 });
await expect(page.locator('text=企业套装').first()).toBeVisible();
await expect(page.locator('text=ERP 管理系统').first()).toBeVisible();
});
test('主导航解决方案下拉菜单可在 hover 时展开', { tag: '@smoke' }, async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
// 等待桌面导航 hydration 完成
await expect(page.locator('nav[aria-label="主导航"]')).toBeVisible({ timeout: 10000 });
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 expectHydrated(solutionsButton);
await expect(async () => {
await page.mouse.move(0, 0);
await solutionsButton.hover();
await expect(solutionsButton).toHaveAttribute('aria-expanded', 'true', { timeout: 1000 });
}).toPass({ timeout: 20000 });
await expect(page.locator('text=行业解决方案').first()).toBeVisible();
await expect(page.locator('text=制造业').first()).toBeVisible();
});
+209 -372
View File
@@ -1,4 +1,4 @@
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page } from './fixtures';
/**
* P1: 品牌视觉审计测试套件
@@ -111,79 +111,71 @@ test.describe('P1: 品牌视觉审计 - 全站文本扫描', { tag: '@regression
// 等待客户端渲染完成
await page.waitForTimeout(2000);
// 获取页面完整文本内容
const pageText = await page.evaluate(() => {
return document.body?.innerText || '';
});
// N-27 修复:原实现先跑一遍 body 正则,只有命中才进入深扫分支,
// 未命中分支的 `expect(true).toBeTruthy()` 是零价值占位 ⇒ 断言路径被守卫。
// 深扫本身就具备「无匹配 ⇒ 计数为 0 ⇒ 通过」的完整语义,改为无条件执行并断言。
// 获取所有包含 novalon 的元素进行详细检查
const elementsWithForbiddenText = await page.evaluate(() => {
const results: Array<{
tag: string;
text: string;
isVisible: boolean;
ariaHidden: boolean;
isTechnicalContext: boolean; // 新增:标记是否为技术性上下文
}> = [];
// 检查是否包含禁止的文本
if (FORBIDDEN_TEXT.test(pageText)) {
// 获取所有包含 novalon 的元素进行详细检查
const elementsWithForbiddenText = await page.evaluate(() => {
const results: Array<{
tag: string;
text: string;
isVisible: boolean;
ariaHidden: boolean;
isTechnicalContext: boolean; // 新增:标记是否为技术性上下文
}> = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null
);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && /novalon/i.test(node.textContent)) {
const parent = node.parentElement;
if (parent) {
const style = window.getComputedStyle(parent);
const text = node.textContent.trim();
// 检查是否为技术性上下文(邮箱、URL等)
const isTechnicalContext =
/@.*\.(cn|com|net|org)/i.test(text) || // 邮箱地址
/https?:\/\/[^\s]*novalon/i.test(text) || // URL
/\.(cn|com|net|org)/i.test(text); // 域名
results.push({
tag: parent.tagName.toLowerCase(),
text: text,
isVisible: style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0',
ariaHidden: parent.getAttribute('aria-hidden') === 'true',
isTechnicalContext,
});
}
}
}
return results;
});
// 过滤掉不可见、aria-hidden 或技术性上下文的元素
const visibleNonTechnicalMatches = elementsWithForbiddenText.filter(
el => el.isVisible && !el.ariaHidden && !el.isTechnicalContext
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null
);
// 首页 Hero 品牌标识中的英文品牌名 NOVALON 是有意展示的内容,予以豁免
const reportMatches = path === '/'
? visibleNonTechnicalMatches.filter(el => el.text.trim().toUpperCase() !== 'NOVALON')
: visibleNonTechnicalMatches;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && /novalon/i.test(node.textContent)) {
const parent = node.parentElement;
if (parent) {
const style = window.getComputedStyle(parent);
const text = node.textContent.trim();
// 断言:不应有可见的非技术性禁止文本
expect(
reportMatches.length,
`发现 ${reportMatches.length} 处可见的非技术性 "novalon" 文本:\n` +
reportMatches.map(el => ` [${el.tag}] "${el.text}"`).join('\n')
).toBe(0);
} else {
// 没有匹配到,测试通过
expect(true).toBeTruthy();
}
// 检查是否为技术性上下文(邮箱、URL等)
const isTechnicalContext =
/@.*\.(cn|com|net|org)/i.test(text) || // 邮箱地址
/https?:\/\/[^\s]*novalon/i.test(text) || // URL
/\.(cn|com|net|org)/i.test(text); // 域名
results.push({
tag: parent.tagName.toLowerCase(),
text: text,
isVisible: style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0',
ariaHidden: parent.getAttribute('aria-hidden') === 'true',
isTechnicalContext,
});
}
}
}
return results;
});
// 过滤掉不可见、aria-hidden 或技术性上下文的元素
const visibleNonTechnicalMatches = elementsWithForbiddenText.filter(
el => el.isVisible && !el.ariaHidden && !el.isTechnicalContext
);
// 首页 Hero 品牌标识中的英文品牌名 NOVALON 是有意展示的内容,予以豁免
const reportMatches = path === '/'
? visibleNonTechnicalMatches.filter(el => el.text.trim().toUpperCase() !== 'NOVALON')
: visibleNonTechnicalMatches;
// 断言:不应有可见的非技术性禁止文本
expect(
reportMatches.length,
`发现 ${reportMatches.length} 处可见的非技术性 "novalon" 文本:\n` +
reportMatches.map(el => ` [${el.tag}] "${el.text}"`).join('\n')
).toBe(0);
});
});
@@ -297,80 +289,18 @@ test.describe('P1: 品牌视觉审计 - 关键位置验证', { tag: '@regression
await safeGoto(page, '/');
await page.waitForTimeout(1000);
// 查找 Logo 元素(放宽选择器条件)
const logoSelectors = [
'header img',
'header svg',
'[data-testid="logo"] img',
'[data-testid="logo"] svg',
'a[href="/"] img', // Logo通常是首页链接
'nav img', // 导航栏中的图片
'.logo img', // class为logo的元素
'#logo', // id为logo的元素
];
// 真实契约(header.tsx:96-118 渲染 `a[aria-label="返回首页"]` 内的明暗双版 Logo 图形):
// Header 必须有一个可见 Logo 图、alt 非空且不含非法文本、src 指向品牌字标资产。
// 旧实现用 8 个猜测式选择器 + `catch {}` 吞异常 + 「页面里只要有 img/svg 就算通过」的兜底,
// 在现行 DOM 下等价于零断言(见 docs/lessons-learned.md §5.21)。
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await expect(logo, 'Header 未渲染可见的品牌 Logo 图形').toBeVisible({ timeout: 10000 });
let logoFound = false;
for (const selector of logoSelectors) {
try {
const logo = page.locator(selector).first();
if (await logo.count() > 0) {
// 检查是否可见(有些可能是隐藏的)
const isVisible = await logo.isVisible().catch(() => false);
if (isVisible) {
logoFound = true;
// 如果是图片,检查 alt 属性或 src 属性
const tagName = await logo.evaluate(el => el.tagName.toLowerCase());
if (tagName === 'img') {
const alt = await logo.getAttribute('alt') || '';
const src = await logo.getAttribute('src') || '';
// 至少应该有 alt 或 src 属性包含品牌信息
const hasBrandInfo = alt.includes(BRAND_NAME) ||
src.toLowerCase().includes('logo') ||
alt.length > 0;
console.log(`Logo found: ${selector}, alt="${alt}", src="${src}"`);
expect(hasBrandInfo).toBeTruthy();
} else if (tagName === 'svg') {
// SVG Logo 应该包含中文品牌名
const svgText = await logo.textContent() || '';
console.log(`SVG Logo found: ${selector}, text="${svgText.substring(0, 50)}"`);
// SVG 可能包含"睿新致远"或其他品牌标识
expect(svgText.length).toBeGreaterThan(0);
}
break;
}
}
} catch {
// 忽略单个选择器的错误,继续尝试下一个
continue;
}
}
// 如果没找到可见的Logo,检查页面中是否有任何图片或SVG(可能结构不同)
if (!logoFound) {
console.log('Standard logo selectors failed, trying broader search...');
// 更广泛的搜索:查找页面中的所有图片和SVG
const allImages = page.locator('img');
const allSvgs = page.locator('svg');
const imageCount = await allImages.count();
const svgCount = await allSvgs.count();
console.log(`Page contains: ${imageCount} images, ${svgCount} SVGs`);
// 只要页面中有图片或SVG,就认为Logo可能存在(只是定位器需要调整)
if (imageCount > 0 || svgCount > 0) {
logoFound = true;
console.log('Found images/SVGs on page - logo likely exists but needs selector adjustment');
}
}
// 最终断言:至少应该找到一些视觉元素
expect(logoFound, '未找到 Header Logo 元素或页面中没有图片/SVG').toBeTruthy();
const alt = await logo.getAttribute('alt') || '';
const src = await logo.getAttribute('src') || '';
expect(alt.length, 'Header Logo 的 alt 为空').toBeGreaterThan(2);
expect(hasVisibleForbiddenText(alt), `Header Logo alt 包含非法文本: ${alt}`).toBe(false);
expect(src, `Header Logo src 应指向品牌字标资产: ${src}`).toContain('logo');
});
test('Footer 应包含 "睿新致远" 品牌信息', async ({ page }) => {
@@ -404,119 +334,46 @@ test.describe('P1: 品牌视觉审计 - 关键位置验证', { tag: '@regression
test('Meta 描述和关键词应使用中文品牌名', async ({ page }) => {
await safeGoto(page, '/');
const description = await page.locator('meta[name="description"]').getAttribute('content');
if (description) {
expect(
hasVisibleForbiddenText(description),
`Meta描述包含非法 "novalon" 文本: ${description}`
).toBe(false);
}
// N-27 修复:原 `if (description)` 守卫 ⇒ meta description 消失时静默通过。
// 根 layout(src/app/layout.tsx:49)恒定声明 description ⇒ 该 meta 是硬契约。
const descriptionMeta = page.locator('meta[name="description"]');
await expect(descriptionMeta).toHaveCount(1);
const description = await descriptionMeta.getAttribute('content');
expect(description, 'meta description 应为非空').toBeTruthy();
expect(
hasVisibleForbiddenText(description ?? ''),
`Meta描述包含非法 "novalon" 文本: ${description}`
).toBe(false);
});
});
test.describe('P1: 品牌视觉审计 - SVG Logo 详细检查', { tag: '@regression' }, () => {
test('Logo SVG 文件不应包含 "NOVALON" 英文文本', async ({ page }) => {
test('Logo 图形资产内的文字受控', async ({ page }) => {
await safeGoto(page, '/');
await page.waitForTimeout(1500);
// 查找所有可能的 Logo 元素(更广泛的选择器)
const logoSelectors = [
'header svg',
'[data-testid="logo"] svg',
'img[src*="logo"]',
'a[href="/"] svg',
'nav svg',
'.logo svg',
'header img',
'[data-testid="logo"] img',
];
let logoChecked = false;
for (const selector of logoSelectors) {
try {
const logos = page.locator(selector);
const count = await logos.count();
if (count > 0) {
for (let i = 0; i < Math.min(count, 3); i++) { // 最多检查前3个
const logo = logos.nth(i);
const tagName = await logo.evaluate(el => el.tagName.toLowerCase());
// 检查是否可见
const isVisible = await logo.isVisible().catch(() => false);
if (!isVisible) continue;
if (tagName === 'svg') {
// 检查 SVG 内部文本内容
const svgTextContent = await logo.evaluate((element) => {
return element.textContent || '';
});
console.log(`SVG found (${selector}): "${svgTextContent.substring(0, 100)}"`);
// 检查是否有 NOVALON 英文文本(在 <text> 元素中)
if (/NOVALON/i.test(svgTextContent)) {
const hasEnglishText = await logo.evaluate((el) => {
const textElements = el.querySelectorAll('text');
for (const textEl of textElements) {
if (textEl.textContent && /NOVALON/i.test(textEl.textContent)) {
return true;
}
}
return false;
});
// 如果确实包含英文NOVALON,这是一个问题
expect(hasEnglishText).toBe(false);
}
// SVG 应该包含中文品牌名或至少有内容
expect(svgTextContent.length).toBeGreaterThan(0);
logoChecked = true;
} else if (tagName === 'img') {
// 如果是 img 标签,检查 src 和 alt
const alt = await logo.getAttribute('alt') || '';
const src = await logo.getAttribute('src') || '';
console.log(`Image Logo found (${selector}): alt="${alt}", src="${src}"`);
// Alt 属性不应包含英文 novalon
if (alt) {
expect(
hasVisibleForbiddenText(alt),
`Logo alt属性包含非法文本: ${alt}`
).toBe(false);
}
logoChecked = true;
}
}
if (logoChecked) break; // 找到一个有效的Logo就停止
}
} catch (e) {
console.log(`Error checking ${selector}:`, e instanceof Error ? e.message : e);
continue;
// 契约:品牌文本通道禁止的是「页面文案里的 novalon」;Logo 图形资产内的拉丁字标
// `NOVALON` 是已批准的品牌资产(本文件 :173 对首页 Hero 的既予豁免 + 8d3bd72
// 「unify calligraphy logo」保留了该 <text> 节点)。因此断言方式是「资产内出现的
// <text> 只能是这个已批准字标」,而不是旧实现的「SVG 必须有 textContent」——
// 纯 <path> 图标的 textContent 恒为空,旧断言必然抛错又被 catch 吞掉(§5.21 假绿根因)。
for (const asset of ['/logo.svg', '/logo-white.svg']) {
const res = await page.request.get(asset);
expect(res.status(), `${asset} 应可取回`).toBe(200);
const svg = await res.text();
const textNodes = [...svg.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map((m) => m[1].trim());
for (const value of textNodes) {
expect(value, `${asset} 内出现未批准的 <text> 内容`).toBe('NOVALON');
}
}
// 如果没有找到任何Logo元素,记录警告但不失败(可能是页面结构特殊)
if (!logoChecked) {
console.log('Warning: No visible SVG/img logo elements found with standard selectors');
// 检查页面中是否存在任何图片或SVG(作为后备验证)
const hasAnyVisualElements = await page.evaluate(() => {
const images = document.querySelectorAll('img');
const svgs = document.querySelectorAll('svg');
return images.length > 0 || svgs.length > 0;
});
console.log(`Page has visual elements: ${hasAnyVisualElements}`);
// 只要有视觉元素存在,就认为测试通过(Logo可能使用了非标准结构)
expect(hasAnyVisualElements).toBeTruthy();
}
// DOM 侧:Header 文本层(读屏与视觉一致的可见文本)不得出现非法 novalon
const headerText = await page.locator('header').innerText();
expect(
hasVisibleForbiddenText(headerText),
'Header 文本层包含非法 "novalon" 文本'
).toBe(false);
});
test('Logo 图片 alt 属性正确设置', async ({ page }) => {
@@ -526,29 +383,27 @@ test.describe('P1: 品牌视觉审计 - SVG Logo 详细检查', { tag: '@regress
const logos = page.locator('header img');
const logoCount = await logos.count();
let withAlt = 0;
for (let i = 0; i < logoCount; i++) {
const logo = logos.nth(i);
try {
// 检查是否可见
const isVisible = await logo.isVisible().catch(() => false);
if (!isVisible) continue;
if (!(await logo.isVisible())) continue;
const alt = await logo.getAttribute('alt');
const alt = await logo.getAttribute('alt');
// alt="" 是装饰图的合法写法,是否缺 alt 由 axe 的 image-alt 规则负责
if (!alt) continue;
withAlt += 1;
if (alt) {
console.log(`Header image ${i}: alt="${alt}"`);
expect(
hasVisibleForbiddenText(alt),
`Logo alt属性包含非法文本: ${alt}`
).toBe(false);
console.log(`Header image ${i}: alt="${alt}"`);
expect(
hasVisibleForbiddenText(alt),
`Logo alt属性包含非法文本: ${alt}`
).toBe(false);
// Alt 应该有合理的长度
expect(alt.length).toBeGreaterThan(2);
}
} catch (e) {
console.log(`Error checking logo ${i}:`, e instanceof Error ? e.message : e);
}
expect(alt.length, `Header 第 ${i} 张图片 alt 过短: "${alt}"`).toBeGreaterThan(2);
}
// 旧实现把上述断言包在 try/catch + console.log 中,一次异常即静默通过(§5.21)
expect(withAlt, 'header 中没有带非空 alt 的可见图片可供检查').toBeGreaterThan(0);
});
});
@@ -561,13 +416,14 @@ test.describe('P1: 品牌视觉审计 - 交互状态一致性', { tag: '@regress
const navLinks = page.locator('nav a, header a');
const linkCount = await navLinks.count();
let hovered = 0;
for (let i = 0; i < Math.min(linkCount, 10); i++) {
const link = navLinks.nth(i);
// 检查链接是否可见(跳过隐藏的链接)
const isVisible = await link.isVisible().catch(() => false);
// 检查链接是否可见(跳过隐藏的链接;isVisible() 对 nth() 精确定位的单元素
// 不会抛错,N-27 修复:去掉 `.catch(() => false)` 吞错壳)
const isVisible = await link.isVisible();
if (!isVisible) {
console.log(`Skipping hidden link ${i}`);
continue;
}
@@ -575,56 +431,58 @@ test.describe('P1: 品牌视觉审计 - 交互状态一致性', { tag: '@regress
// 悬停状态
await link.hover({ timeout: 2000 });
await page.waitForTimeout(100);
// 检查 tooltip 或 title 属性
const title = await link.getAttribute('title');
if (title) {
expect(
hasVisibleForbiddenText(title),
`导航链接标题包含非法 "novalon" 文本: ${title}`
).toBe(false);
}
} catch (hoverError) {
// 悬停本身可能因遮挡失败:这是环境噪声,但必须留下计数(旧实现连失败都不统计)
console.log(`Hover failed for link ${i}:`, hoverError instanceof Error ? hoverError.message : hoverError);
// 忽略悬停失败,继续检查下一个
continue;
}
hovered += 1;
// hover 后链接自身文本与 title 都不得引入非法品牌文本(每次都断言,不再依赖 title 是否存在)
expect(
hasVisibleForbiddenText((await link.innerText()).trim()),
`导航链接 hover 后文本包含非法 "novalon"`
).toBe(false);
// N-27 修复:title 为可选属性,原 `if (title)` 让断言只在属性存在时执行;
// 改为无条件检查(缺省按空串处理,结果同样必须为 false)
const title = await link.getAttribute('title');
expect(
hasVisibleForbiddenText(title ?? ''),
`导航链接标题包含非法 "novalon" 文本: ${title ?? ''}`
).toBe(false);
}
expect(hovered, '未成功 hover 任何导航链接,本用例等于零断言').toBeGreaterThan(0);
});
test('移动端菜单展开后品牌显示正常', async ({ page }) => {
// 设置移动端视口
await page.setViewportSize({ width: 375, height: 667 });
// N-27 修复:本例显式设到 375×667(< md 断点 768px),header.tsx:192 的
// mobile-menu-button 在此视口恒渲染 ⇒ 原 `isVisible().catch(() => false)` +
// test.skip 兜底属于可省壳,改为硬断言(按钮消失即红,而不是静默跳过)。
try {
await safeGoto(page, '/');
await page.waitForTimeout(1500);
// 点击移动端菜单按钮
const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"], button[aria-label*="menu"], .hamburger, .menu-toggle');
if (await mobileMenuButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await mobileMenuButton.click();
await page.waitForTimeout(500);
// 真实 DOM:header.tsx:189 的 data-testid="mobile-menu-button",抽屉为 :222 的 mobile-navigation。
// 旧实现把 4 个选择器并成一个 locator,多匹配时 isVisible() 触发 strict-mode 抛错,
// 再被外层 catch 吞成 expect(true)(§5.21)。
const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]');
await expect(mobileMenuButton).toBeVisible({ timeout: 5000 });
// 检查菜单内容
const mobileNav = page.locator('[data-testid="mobile-navigation"], nav.mobile, .mobile-menu');
if (await mobileNav.isVisible({ timeout: 2000 }).catch(() => false)) {
const navText = await mobileNav.textContent();
await mobileMenuButton.click();
const mobileNav = page.locator('[data-testid="mobile-navigation"]');
await expect(mobileNav, '点击菜单按钮后移动端导航未展开').toBeVisible({ timeout: 5000 });
expect(
hasVisibleForbiddenText(navText || ''),
`移动端导航包含非法 "novalon" 文本`
).toBe(false);
}
} else {
console.log('Mobile menu button not found - may use different structure');
// 即使没有找到移动端菜单按钮,也不应该失败(可能是桌面版布局)
expect(true).toBeTruthy();
}
} catch (error) {
console.log('Mobile menu test error:', error instanceof Error ? error.message : error);
// 移动端测试失败不应阻断整个测试套件
expect(true).toBeTruthy(); // 标记为通过,但记录问题
const navText = await mobileNav.textContent();
expect(
hasVisibleForbiddenText(navText || ''),
`移动端导航包含非法 "novalon" 文本`
).toBe(false);
} finally {
// 恢复默认视口
await page.setViewportSize({ width: 1280, height: 720 });
@@ -658,15 +516,9 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
await safeGoto(page, '/');
}
} catch (error) {
// 导航失败是真缺陷,不再按视口特例伪造通过(safeGoto 自带重试)
console.log(`Navigation failed for ${name}:`, error instanceof Error ? error.message : error);
// 如果导航失败,标记为跳过但不失败
if (isMobileS) {
console.log(`Skipping ${name} due to navigation timeout`);
expect(true).toBeTruthy(); // 标记为通过
return;
} else {
throw error; // 其他视口仍然抛出错误
}
throw error;
}
// 增加等待时间,确保响应式布局渲染完成
@@ -676,16 +528,10 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
const header = page.locator('header');
await expect(header).toBeVisible({ timeout: 10000 });
// Logo 可见(允许某些视口下 Logo 不可见的情况)
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
if (await logo.count() > 0) {
try {
await expect(logo).toBeVisible({ timeout: 2000 });
} catch {
// 某些小屏幕下 Logo 可能不可见,这是可接受的
console.log(`Warning: Logo not visible in ${name} viewport`);
}
}
// Logo 在每个断点都必须可见:旧实现用 `if (count > 0)` + catch 把「查不到」与「不可见」
// 两条路都通向通过
const logo = page.locator('header img:visible, header svg:visible').first();
await expect(logo, `${name} 视口下 Header 没有可见 Logo`).toBeVisible({ timeout: 5000 });
// Footer 可见且包含品牌
const footer = page.locator('[data-testid="footer"], footer').first();
@@ -704,16 +550,19 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', { tag: '@regression' }, () => {
test('404 错误页面品牌显示正常', async ({ page }) => {
// 访问一个不存在的页面
await safeGoto(page, '/nonexistent-page-12345')
.then(() => true)
.catch(() => false);
// N-27 修复:原 `.then(() => true).catch(() => false)` 把导航失败吞成
// 「继续在空白页上断言」⇒ 页面根本没渲染也绿。导航失败现在直接变红,
// 并且必须先证明自定义 404 页面真的渲染了(not-found-content.tsx:14-16 的 h1「404」),
// 再检查品牌文本。
await safeGoto(page, '/nonexistent-page-12345');
// 等待自定义 404 页面渲染
await page.waitForTimeout(1000);
await expect(page.locator('h1').first()).toHaveText('404');
// 即使是 404 页面,也不应显示 novalon(排除技术性引用)
const bodyText = await page.evaluate(() => document.body?.innerText || '');
expect(bodyText.length, '404 页面正文不应为空').toBeGreaterThan(0);
expect(
hasVisibleForbiddenText(bodyText),
`404 页面包含非法 "novalon" 文本`
@@ -734,23 +583,27 @@ test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', { tag: '@regression
'blockquote',
];
let checked = 0;
for (const selector of testimonialSelectors) {
try {
const element = page.locator(selector).first();
if (await element.count() > 0 && await element.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await element.textContent();
if (text && text.trim().length > 0) {
expect(
hasVisibleForbiddenText(text),
`客户评价区域包含非法 "novalon" 文本`
).toBe(false);
}
}
} catch (e) {
console.log(`Error checking ${selector}:`, e instanceof Error ? e.message : e);
continue;
}
const element = page.locator(selector).first();
if (await element.count() === 0) continue;
// N-27 修复:去掉 `isVisible({timeout}).catch(() => false)` 吞错壳;
// 不可见选择器本就可跳过,真正的地板是下方无条件的 checked > 0 断言。
if (!(await element.isVisible())) continue;
const text = (await element.textContent()) || '';
if (!text.trim()) continue;
checked += 1;
expect(
hasVisibleForbiddenText(text),
`客户评价区域包含非法 "novalon" 文本`
).toBe(false);
}
// 首页确实渲染 blockquote(social-proof-section.tsx:102),因此零命中就是契约被破坏,
// 不能像旧实现那样走「catch + console.log」静默通过(§5.21)
expect(checked, '未找到任何可见的客户评价区域,本用例等于零断言').toBeGreaterThan(0);
});
test('打印视图品牌信息保留', async ({ page }) => {
@@ -782,14 +635,9 @@ test.describe('P1: 品牌视觉审计 - 性能与截图基线', { tag: '@regress
await safeGoto(page, '/');
// 等待 Logo 加载完成(使用更宽松的超时)
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
try {
await logo.waitFor({ state: 'visible', timeout: 10000 });
} catch {
console.log('Logo not visible within 10s - may use different structure');
// Logo 可能使用了非标准结构,继续测试
}
// 等待 Logo 可见;10s 内不可见即失败(旧实现 catch 后继续跑,等于不做检查)
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await logo.waitFor({ state: 'visible', timeout: 10000 });
const loadTime = Date.now() - startTime;
@@ -797,42 +645,31 @@ test.describe('P1: 品牌视觉审计 - 性能与截图基线', { tag: '@regress
expect(loadTime).toBeLessThan(30000);
});
test('生成品牌视觉截图用于人工审核', async ({ page }) => {
test('生成品牌视觉截图用于人工审核', async ({ page }, testInfo) => {
// 设置更长的超时时间用于截图
test.setTimeout(90000);
await safeGoto(page, '/');
await page.waitForTimeout(1500);
try {
// 截取 Header 区域
const header = page.locator('header');
await expect(header).toBeVisible({ timeout: 10000 });
await header.screenshot({
path: 'test-results/p1-brand-audit-header.png',
fullPage: false,
}).catch((e) => console.log('Header screenshot failed:', e instanceof Error ? e.message : e));
// 截图是人工审核交付物,必须真正产出非空 PNG 并挂到报告附件上。
// 旧实现三处 `.catch(e => console.log(...))` + 结尾 expect(true),一张都没截到也算通过(§5.21)。
const header = page.locator('header');
await expect(header, 'Header 区域不可见').toBeVisible({ timeout: 10000 });
const headerPng = await header.screenshot({ fullPage: false });
expect(headerPng.byteLength, 'Header 截图为空').toBeGreaterThan(1000);
await testInfo.attach('p1-brand-audit-header', { body: headerPng, contentType: 'image/png' });
// 截取 Footer 区域
const footer = page.locator('[data-testid="footer"], footer').first();
await expect(footer).toBeVisible({ timeout: 10000 });
await footer.screenshot({
path: 'test-results/p1-brand-audit-footer.png',
fullPage: false,
}).catch((e) => console.log('Footer screenshot failed:', e instanceof Error ? e.message : e));
const footer = page.locator('[data-testid="footer"], footer').first();
await expect(footer, 'Footer 区域不可见').toBeVisible({ timeout: 10000 });
const footerPng = await footer.screenshot({ fullPage: false });
expect(footerPng.byteLength, 'Footer 截图为空').toBeGreaterThan(1000);
await testInfo.attach('p1-brand-audit-footer', { body: footerPng, contentType: 'image/png' });
// 截取 Logo 特写
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
if (await logo.count() > 0 && await logo.isVisible({ timeout: 3000 }).catch(() => false)) {
await logo.screenshot({
path: 'test-results/p1-brand-audit-logo.png',
}).catch((e) => console.log('Logo screenshot failed:', e instanceof Error ? e.message : e));
}
} catch (error) {
console.log('Screenshot generation encountered an error:', error instanceof Error ? error.message : error);
}
// 测试通过(截图用于人工审核,即使部分失败也不应阻断)
expect(true).toBeTruthy();
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await expect(logo, 'Logo 图形不可见').toBeVisible({ timeout: 10000 });
const logoPng = await logo.screenshot();
expect(logoPng.byteLength, 'Logo 截图为空').toBeGreaterThan(200);
await testInfo.attach('p1-brand-audit-logo', { body: logoPng, contentType: 'image/png' });
});
});
File diff suppressed because it is too large Load Diff
+39 -34
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
import { pollBoundingBox } from './assert-helpers';
/**
* P3: 多浏览器与多设备兼容性测试
@@ -38,16 +39,17 @@ test.describe('跨浏览器 - 核心页面渲染', { tag: '@regression' }, () =>
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const logo = page.locator('header img, header svg').first();
if (await logo.count() > 0) {
await expect(logo).toBeVisible();
// N-27 修复:原守卫 `if (logo.count() > 0)` 让 Logo 消失时静默通过。
// 证据:header.tsx:96-121 恒定渲染 a[aria-label="返回首页"],内含明暗两版
// logo(按 html[data-theme] 显隐,恰有一个可见),CSS h-8 md:h-10 ⇒
// 高度 32/40px、宽度随 160:40 比例自动缩放,桌面与移动断点均成立。
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await expect(logo).toBeVisible();
// 检查 Logo 尺寸合理
const box = await logo.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThan(50);
expect(box!.height).toBeGreaterThan(20);
}
// 检查 Logo 尺寸合理
const box = await pollBoundingBox(logo);
expect(box.width).toBeGreaterThan(50);
expect(box.height).toBeGreaterThan(20);
});
test(`${name} (${path}) - 导航菜单可交互`, async ({ page }) => {
@@ -116,15 +118,17 @@ test.describe('多设备 - 视口响应式布局', { tag: '@regression' }, () =>
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// N-27 修复:本组用例显式设到移动视口(≤428px < md 断点),
// header.tsx:192 的 mobile-menu-button(min 44×44px)必须可见。
// 原守卫 `if (isVisible)` ⇒ 按钮消失时静默通过;改为硬断言 + 轮询盒模型。
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
if (await mobileMenuBtn.isVisible()) {
// 按钮应在可视区域内
const box = await mobileMenuBtn.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 10); // 允许小误差
}
await expect(mobileMenuBtn).toBeVisible();
// 按钮应在可视区域内(pollBoundingBox 等待节点稳定,超时即红)
const box = await pollBoundingBox(mobileMenuBtn);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 10); // 允许小误差
});
}
}
@@ -181,6 +185,10 @@ test.describe('特定浏览器 - 行为差异检查', { tag: '@regression' }, ()
const images = page.locator('img');
const imageCount = await images.count();
// N-27 修复:原 `if (imageCount > 0)` 守卫——首页 img 数为 0 时本例静默通过。
// header.tsx:103-121 恒渲染两枚 logo <img>(priority/eager),img 数为 0 即真实回归。
expect(imageCount).toBeGreaterThan(0);
let loadedCount = 0;
for (let i = 0; i < Math.min(imageCount, 10); i++) {
@@ -189,30 +197,27 @@ test.describe('特定浏览器 - 行为差异检查', { tag: '@regression' }, ()
if (naturalWidth > 0) loadedCount++;
}
// 至少应该有一些图片加载成功
if (imageCount > 0) {
expect(loadedCount).toBeGreaterThan(0);
}
// 至少应该有一些图片加载成功(无条件断言)
expect(loadedCount).toBeGreaterThan(0);
});
test('表单元素样式一致', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(3000);
// Check for any visible form inputs (selectors vary by page implementation)
const inputs = page.locator('input:visible, textarea:visible');
// N-27 修复:原双守卫 `if (inputCount > 0)` + `if (box)` 使本例在表单消失时
// 静默通过。联系页表单为恒定契约(contact-content-v3 渲染必填输入),
// 改为主内容区内作用域的硬断言 + 轮询盒模型。
const inputs = page.locator('main input:visible, main textarea:visible');
const inputCount = await inputs.count();
expect(inputCount).toBeGreaterThan(0);
if (inputCount > 0) {
for (let i = 0; i < Math.min(inputCount, 5); i++) {
const input = inputs.nth(i);
// Verify input is visible and has reasonable dimensions
const box = await input.boundingBox();
if (box) {
expect(box.width).toBeGreaterThan(50);
expect(box.height).toBeGreaterThan(20);
}
}
for (let i = 0; i < Math.min(inputCount, 5); i++) {
const input = inputs.nth(i);
// Verify input is visible and has reasonable dimensions
const box = await pollBoundingBox(input);
expect(box.width).toBeGreaterThan(50);
expect(box.height).toBeGreaterThan(20);
}
});
});
+330 -233
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "./fixtures";
/**
* P4: 性能与可访问性审计
@@ -13,12 +13,14 @@ import { test, expect } from '@playwright/test';
test.setTimeout(60000);
// ==================== 1. 性能指标测试 ====================
test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
test.describe("性能 - 页面加载指标", { tag: "@regression" }, () => {
test("首页 DOMContentLoaded 时间 < 3秒", async ({ page }) => {
// 默认口径的 webServer 是 npm run dev:路由首次访问要现场编译,不预热就把编译器耗时算成页面耗时。
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
test('首页 DOMContentLoaded 时间 < 3秒', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const loadTime = Date.now() - startTime;
console.log(`DOMContentLoaded time: ${loadTime}ms`);
@@ -26,32 +28,36 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
expect(loadTime).toBeLessThan(3000);
});
test('首页完整加载时间 < 8秒', async ({ page }) => {
test("首页完整加载时间 < 8秒", async ({ page }) => {
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
const startTime = Date.now();
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(1000); // 等待客户端渲染
await page.goto("/", { waitUntil: "load", timeout: 30000 });
const loadTime = Date.now() - startTime;
await page.waitForTimeout(1000); // 等待客户端渲染,不计入加载耗时
console.log(`Full load time: ${loadTime}ms`);
expect(loadTime).toBeLessThan(8000);
});
test('关键页面加载时间在合理范围', async ({ page }) => {
test("关键页面加载时间在合理范围", async ({ page }) => {
const pages = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' },
{ path: "/", name: "首页" },
{ path: "/about", name: "关于我们" },
{ path: "/contact", name: "联系我们" },
];
for (const { path, name } of pages) {
await page.goto(path, { waitUntil: "commit", timeout: 30000 });
const startTime = Date.now();
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
await page.goto(path, { waitUntil: "domcontentloaded", timeout: 30000 });
const loadTime = Date.now() - startTime;
await page.waitForTimeout(1500); // 让水合后的 DOM 稳定,不计入加载耗时
console.log(`${name} load time: ${loadTime}ms`);
// 每个页面应该在5秒内完成基本加载
@@ -59,28 +65,37 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
}
});
test('首屏内容快速可见 (FCP < 2s)', async ({ page }) => {
test("首屏内容快速可见 (FCP < 2s)", async ({ page }) => {
// 使用 Performance API 测量
const fcpMetrics = await page.goto('/', {
waitUntil: 'domcontentloaded',
timeout: 30000,
}).then(async () => {
await page.waitForTimeout(1000);
return await page.evaluate(() => {
const paintEntries = performance.getEntriesByType('paint')
.filter(entry => entry.name === 'first-contentful-paint');
// N-27:去掉 `if (fcpMetrics)` 守卫——单次 evaluate 时机过早时原实现会静默少测。
// 改为轮询直到 FCP 条目出现(paint timing 四浏览器引擎均支持),拿不到 ⇒ 用例失败。
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const readFcp = () =>
page.evaluate(() => {
const paintEntries = performance
.getEntriesByType("paint")
.filter((entry) => entry.name === "first-contentful-paint");
return paintEntries.length > 0 ? paintEntries[0].startTime : null;
});
});
if (fcpMetrics) {
console.log(`FCP: ${fcpMetrics}ms`);
expect(fcpMetrics).toBeLessThan(2000);
await expect
.poll(readFcp, {
timeout: 15000,
message: "等待 first-contentful-paint 度量条目出现",
})
.not.toBeNull();
const fcpMetrics = await readFcp();
if (fcpMetrics === null) {
throw new Error("first-contentful-paint 度量条目在轮询通过后仍缺失");
}
console.log(`FCP: ${fcpMetrics}ms`);
expect(fcpMetrics).toBeLessThan(2000);
});
test('页面不阻塞主线程过长', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("页面不阻塞主线程过长", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
// 检查长任务(Long Tasks)
@@ -89,7 +104,7 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
const observer = new PerformanceObserver((list) => {
resolve(list.getEntries().length);
});
observer.observe({ entryTypes: ['longtask'] });
observer.observe({ entryTypes: ["longtask"] });
// 3秒后如果没有长任务,返回0
setTimeout(() => resolve(0), 3000);
@@ -103,21 +118,20 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
});
// ==================== 2. 资源优化测试 ====================
test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
test('图片使用适当格式和尺寸', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test.describe("性能 - 资源优化", { tag: "@regression" }, () => {
test("图片使用适当格式和尺寸", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
const imageAnalysis = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
const images = Array.from(document.querySelectorAll("img"));
let optimizedCount = 0;
let largeImages = 0;
images.forEach(img => {
images.forEach((img) => {
// 检查图片是否使用了现代格式
const src = img.src.toLowerCase();
if (src.includes('.webp') || src.includes('.avif')) {
if (src.includes(".webp") || src.includes(".avif")) {
optimizedCount++;
}
@@ -139,19 +153,24 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
};
});
console.log('Image analysis:', imageAnalysis);
console.log("Image analysis:", imageAnalysis);
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
});
test('CSS 和 JS 资源非阻塞加载', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("CSS 和 JS 资源非阻塞加载", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
const resourceTiming = await page.evaluate(() => {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const entries = performance.getEntriesByType(
"resource",
) as PerformanceResourceTiming[];
return entries
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'stylesheet')
.map(e => ({
name: e.name.split('/').pop(),
.filter(
(e) =>
e.initiatorType === "script" || e.initiatorType === "stylesheet",
)
.map((e) => ({
name: e.name.split("/").pop(),
type: e.initiatorType,
duration: Math.round(e.duration),
transferSize: e.transferSize,
@@ -159,13 +178,14 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
});
// 输出资源加载信息用于分析
console.log('Resource timing:', resourceTiming.slice(0, 10));
console.log("Resource timing:", resourceTiming.slice(0, 10));
// 关键资源不应阻塞太久(简化检查)
const criticalResources = resourceTiming.filter(r =>
r.name?.includes('main') ||
r.name?.includes('chunk') ||
r.name?.includes('app')
const criticalResources = resourceTiming.filter(
(r) =>
r.name?.includes("main") ||
r.name?.includes("chunk") ||
r.name?.includes("app"),
);
for (const resource of criticalResources) {
@@ -174,24 +194,25 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
}
});
test('字体加载策略合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("字体加载策略合理", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
const fontInfo = await page.evaluate(() => {
const fonts = performance.getEntriesByType('resource')
.filter((e: PerformanceResourceTiming) => e.initiatorType === 'font');
const fonts = performance
.getEntriesByType("resource")
.filter((e: PerformanceResourceTiming) => e.initiatorType === "font");
return {
fontCount: fonts.length,
fonts: fonts.map((f: PerformanceResourceTiming) => ({
name: f.name.split('/').pop(),
name: f.name.split("/").pop(),
duration: Math.round(f.duration),
})),
};
});
console.log('Font loading:', fontInfo);
console.log("Font loading:", fontInfo);
// 字体数量不应过多
expect(fontInfo.fontCount).toBeLessThanOrEqual(10);
@@ -199,50 +220,54 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
});
// ==================== 3. 可访问性基础测试 ====================
test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () => {
test.describe("可访问性 - WCAG 2.1 AA 合规", { tag: "@regression" }, () => {
test.beforeEach(async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);
});
test('所有交互元素可通过键盘访问', async ({ page }) => {
test("所有交互元素可通过键盘访问", async ({ page }) => {
// Count focusable elements (basic accessibility check)
const focusableCount = await page.evaluate(() => {
return document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
).length;
});
console.log(`Found ${focusableCount} focusable elements`);
expect(focusableCount).toBeGreaterThan(0);
// Click on the page first to ensure it has focus context
await page.locator('body').click();
await page.waitForTimeout(300);
// Try keyboard navigation - press Tab to move focus
await page.keyboard.press('Tab');
await page.waitForTimeout(300);
// Check if any element received focus (may not work in all browsers)
const hasFocus = await page.evaluate(() => document.activeElement !== document.body);
// Skip strict assertion; keyboard tab behavior varies by browser/OS settings
expect(typeof hasFocus).toBe('boolean');
// N-27:Tab 是否把焦点移入页面内容存在引擎差异(webkit 与 chromium-mobile 实测不移),
// 而原断言 `expect(typeof hasFocus).toBe('boolean')` 恒真,正是为掩盖这点。
// 改成与引擎无关的确定性检查:页面里的可聚焦元素确实可获得焦点。
const firstFocusable = page
.locator("a[href], button:not([disabled]), input, select, textarea")
.first();
await expect(firstFocusable).toBeVisible();
await firstFocusable.focus();
await expect(firstFocusable).toBeFocused();
});
test('表单标签关联正确', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("表单标签关联正确", async ({ page }) => {
await page.goto("/contact", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await page.waitForTimeout(2500);
// 检查表单输入框是否有关联的 label
const formInputs = await page.evaluate(() => {
const inputs = document.querySelectorAll('input, textarea, select');
const results: Array<{ tag: string; id: string; hasLabel: boolean; ariaLabel: string | null }> = [];
const inputs = document.querySelectorAll("input, textarea, select");
const results: Array<{
tag: string;
id: string;
hasLabel: boolean;
ariaLabel: string | null;
}> = [];
inputs.forEach(input => {
inputs.forEach((input) => {
const id = input.id;
const ariaLabel = input.getAttribute('aria-label');
const ariaLabel = input.getAttribute("aria-label");
// 检查是否有显式 label
let hasLabel = false;
@@ -253,7 +278,7 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
results.push({
tag: input.tagName,
id: id || '',
id: id || "",
hasLabel,
ariaLabel,
});
@@ -267,27 +292,32 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
const hasAccessibleName = input.hasLabel || input.ariaLabel;
expect(
hasAccessibleName,
`Input ${input.tag}${input.id ? '#' + input.id : ''} 缺少可访问名称`
`Input ${input.tag}${input.id ? "#" + input.id : ""} 缺少可访问名称`,
).toBeTruthy();
}
});
test('颜色对比度符合标准(简化版)', async ({ page }) => {
test("颜色对比度符合标准(简化版)", async ({ page }) => {
// 这是一个简化的对比度检查,完整检查应使用 axe-core 或类似工具
const contrastIssues = await page.evaluate(() => {
const issues: string[] = [];
// 检查主要文本元素
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, a, li, td, th');
const textElements = document.querySelectorAll(
"p, h1, h2, h3, h4, h5, h6, span, a, li, td, th",
);
textElements.forEach(el => {
textElements.forEach((el) => {
const style = window.getComputedStyle(el);
const color = style.color;
const bgColor = style.backgroundColor;
// 简化检查:确保文本不是白色背景上的浅色文字
// 完整实现应计算相对亮度比值
if (color === 'rgb(255, 255, 255)' && bgColor === 'rgb(255, 255, 255)') {
if (
color === "rgb(255, 255, 255)" &&
bgColor === "rgb(255, 255, 255)"
) {
issues.push(`${el.tagName}: 白色文字在白色背景上`);
}
});
@@ -295,26 +325,49 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
return issues;
});
expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0);
expect(
contrastIssues.length,
`发现颜色对比度问题:\n${contrastIssues.join("\n")}`,
).toBe(0);
});
test('ARIA 标签使用正确', async ({ page }) => {
test("ARIA 标签使用正确", async ({ page }) => {
// 检查 ARIA 属性的使用情况
const ariaUsage = await page.evaluate(() => {
const elementsWithAria = document.querySelectorAll('[role], [aria-label], [aria-live], [aria-expanded]');
const elementsWithAria = document.querySelectorAll(
"[role], [aria-label], [aria-live], [aria-expanded]",
);
const issues: string[] = [];
elementsWithAria.forEach(el => {
const role = el.getAttribute('role');
elementsWithAria.forEach((el) => {
const role = el.getAttribute("role");
// 如果有 role,应该有相应的语义
if (role) {
// 检查 role 是否有效
const validRoles = [
'button', 'link', 'navigation', 'main', 'banner', 'contentinfo',
'dialog', 'alert', 'menu', 'menuitem', 'checkbox', 'radio',
'textbox', 'combobox', 'listbox', 'option', 'tree', 'treeitem',
'tab', 'tabpanel', 'progressbar', 'tooltip'
"button",
"link",
"navigation",
"main",
"banner",
"contentinfo",
"dialog",
"alert",
"menu",
"menuitem",
"checkbox",
"radio",
"textbox",
"combobox",
"listbox",
"option",
"tree",
"treeitem",
"tab",
"tabpanel",
"progressbar",
"tooltip",
];
if (!validRoles.includes(role!)) {
@@ -329,180 +382,200 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
};
});
console.log('ARIA usage:', ariaUsage);
expect(ariaUsage.issues.length, `ARIA 使用问题:\n${ariaUsage.issues.join('\n')}`).toBe(0);
console.log("ARIA usage:", ariaUsage);
expect(
ariaUsage.issues.length,
`ARIA 使用问题:\n${ariaUsage.issues.join("\n")}`,
).toBe(0);
});
test('焦点管理清晰可见', async ({ page }) => {
test("焦点管理清晰可见", async ({ page }) => {
// Tab 到第一个链接并检查焦点样式
await page.keyboard.press('Tab');
// N-27:去掉 `if (focusedElement.count() > 0)` 守卫。'/' 首个 Tab 焦点落在跳转链接
// (layout.tsx:201),按不到焦点元素本身就应失败。
await page.keyboard.press("Tab");
await page.waitForTimeout(200);
const focusedElement = page.locator(':focus').first();
if (await focusedElement.count() > 0) {
// 检查焦点元素是否有明显的焦点样式(outline 或 box-shadow)
const hasOutlineStyle = await focusedElement.evaluate((el) => {
const style = window.getComputedStyle(el);
return (
style.outlineStyle !== 'none' ||
style.boxShadow !== 'none' ||
el.hasAttribute('data-focus-visible')
);
});
const focusedElement = page.locator(":focus").first();
await expect(focusedElement).toBeAttached();
// 至少应该有一些方式指示焦点(某些浏览器默认行为)
console.log('Focused element has visible focus indicator:', hasOutlineStyle);
}
// 检查焦点元素是否有明显的焦点样式(outline 或 box-shadow)
const hasOutlineStyle = await focusedElement.evaluate((el) => {
const style = window.getComputedStyle(el);
return (
style.outlineStyle !== "none" ||
style.boxShadow !== "none" ||
el.hasAttribute("data-focus-visible")
);
});
// N-27:原实现只 console.log 这个布尔值,全程没有任何断言 ⇒ 焦点样式被删掉也照样绿。
expect(hasOutlineStyle).toBe(true);
});
test('跳转链接功能正常', async ({ page }) => {
test("跳转链接功能正常", async ({ page }) => {
// Find the skip-to-content link
const skipLink = page.locator('[data-skip-to-content]').first();
// N-27:去掉 `if (count() > 0)` 守卫。跳转链接由根布局无条件渲染(layout.tsx:201-207),
// 从 DOM 移除即失败。
const skipLink = page.locator("[data-skip-to-content]").first();
await expect(skipLink).toBeVisible();
if (await skipLink.count() > 0) {
// Focus the skip link first (it's off-screen until focused)
await skipLink.focus();
await page.waitForTimeout(200);
// Focus the skip link first (it's off-screen until focused)
await skipLink.focus();
await page.waitForTimeout(200);
// Navigate to the main content via keyboard
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Navigate to the main content via keyboard
await page.keyboard.press("Enter");
await page.waitForTimeout(500);
// Focus should have moved to the main content area
const activeElementId = await page.evaluate(() => document.activeElement?.id || '');
const urlHash = page.url().includes('#main-content');
// Focus should have moved to the main content area
const activeElementId = await page.evaluate(
() => document.activeElement?.id || "",
);
const urlHash = page.url().includes("#main-content");
// Either the main element is focused or the URL hash changed
expect(activeElementId || urlHash).toBeTruthy();
}
// Either the main element is focused or the URL hash changed
expect(activeElementId || urlHash).toBeTruthy();
});
test('错误消息对屏幕阅读器友好', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("错误消息对屏幕阅读器友好", async ({ page }) => {
await page.goto("/contact", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
await page.waitForTimeout(2500);
// 尝试提交空表单以触发验证错误
const submitButton = page.locator('[data-testid="submit-button"], button[type="submit"]').first();
if (await submitButton.isVisible()) {
await submitButton.click();
await page.waitForTimeout(500);
// N-27:去掉 `if (isVisible)` 与 `if (errorCount > 0)` 双守卫——提交按钮
// (contact-content-v3.tsx 的 submit-button testid)与空表单校验错误
// (zod safeParse 5 条,labeled-input.tsx:56-65 role="alert")都是页面的确定装配,
// 任一消失 ⇒ 用例失败而不是静默通过。
const submitButton = page
.locator('[data-testid="submit-button"], button[type="submit"]')
.first();
await expect(submitButton).toBeVisible();
await submitButton.click();
// 检查错误消息是否使用适当的 ARIA 属性
const errorMessages = page.locator('[role="alert"], [data-testid="error-message"], .error-message');
const errorCount = await errorMessages.count();
const errorMessages = page.locator(
'[role="alert"], [data-testid="error-message"], .error-message',
);
await expect(errorMessages.first()).toBeVisible({ timeout: 5000 });
const errorCount = await errorMessages.count();
expect(errorCount).toBeGreaterThan(0);
if (errorCount > 0) {
for (let i = 0; i < errorCount; i++) {
const errorMsg = errorMessages.nth(i);
const role = await errorMsg.getAttribute('role');
const ariaLive = await errorMsg.getAttribute('aria-live');
for (let i = 0; i < errorCount; i++) {
const errorMsg = errorMessages.nth(i);
const role = await errorMsg.getAttribute("role");
const ariaLive = await errorMsg.getAttribute("aria-live");
// 错误消息应该有 alert role 或 aria-live 属性
const isAccessible =
role === 'alert' ||
role === 'status' ||
ariaLive === 'assertive' ||
ariaLive === 'polite';
// 错误消息应该有 alert role 或 aria-live 属性
const isAccessible =
role === "alert" ||
role === "status" ||
ariaLive === "assertive" ||
ariaLive === "polite";
expect(isAccessible, `错误消息缺少必要的 ARIA 属性`).toBeTruthy();
}
}
expect(isAccessible, `错误消息缺少必要的 ARIA 属性`).toBeTruthy();
}
});
});
// ==================== 4. SEO 与元数据测试 ====================
test.describe('SEO - 元数据完整性', { tag: '@regression' }, () => {
test('各主要页面有唯一标题', async ({ page }) => {
test.describe("SEO - 元数据完整性", { tag: "@regression" }, () => {
test("各主要页面有唯一标题", async ({ page }) => {
const pages = [
'/',
'/about',
'/contact',
'/products',
'/services',
'/solutions',
"/",
"/about",
"/contact",
"/products",
"/services",
"/solutions",
];
const titles: string[] = [];
for (const path of pages) {
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.goto(path, { waitUntil: "domcontentloaded", timeout: 30000 });
const title = await page.title();
titles.push(title);
}
// 检查标题唯一性
const uniqueTitles = new Set(titles);
console.log(`Found ${uniqueTitles.size} unique titles out of ${pages.length} pages`);
console.log(
`Found ${uniqueTitles.size} unique titles out of ${pages.length} pages`,
);
// 至少应该有一半以上的页面有不同标题
expect(uniqueTitles.size).toBeGreaterThanOrEqual(Math.ceil(pages.length / 2));
expect(uniqueTitles.size).toBeGreaterThanOrEqual(
Math.ceil(pages.length / 2),
);
});
test('Meta Description 存在且长度合适', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("Meta Description 存在且长度合适", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
// N-27:去掉 `if (count > 0)` 守卫。description 由根布局 metadata 无条件输出
// (src/app/layout.tsx:50,64 个字符,落在 50–160 区间内)。
const metaDescription = page.locator('meta[name="description"]');
const count = await metaDescription.count();
if (count > 0) {
const content = await metaDescription.first().getAttribute('content');
expect(content).toBeTruthy();
expect(content!.length).toBeGreaterThanOrEqual(50); // 不宜过短
expect(content!.length).toBeLessThanOrEqual(160); // SEO 推荐长度
}
await expect(metaDescription).toHaveCount(1);
const content = await metaDescription.getAttribute("content");
expect(content).toBeTruthy();
expect(content!.length).toBeGreaterThanOrEqual(50); // 不宜过短
expect(content!.length).toBeLessThanOrEqual(160); // SEO 推荐长度
});
test('Open Graph 标签存在(社交媒体分享)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("Open Graph 标签存在(社交媒体分享)", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
// N-27:去掉 `if (hasOgTitle)` 守卫。openGraph.title/description/images
// 由根布局 metadata 无条件输出(src/app/layout.tsx:65-80)。
const ogTitle = page.locator('meta[property="og:title"]');
await expect(ogTitle).toHaveCount(1);
const content = await ogTitle.getAttribute("content");
expect(content).toBeTruthy();
const ogDescription = page.locator('meta[property="og:description"]');
const ogImage = page.locator('meta[property="og:image"]');
// 至少应该有 OG 标题
const hasOgTitle = await ogTitle.count() > 0;
if (hasOgTitle) {
const content = await ogTitle.getAttribute('content');
expect(content).toBeTruthy();
}
console.log('OG tags present:', {
console.log("OG tags present:", {
title: await ogTitle.count(),
description: await ogDescription.count(),
image: await ogImage.count(),
});
});
test('Canonical URL 正确设置', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("Canonical URL 正确设置", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
// N-27:去掉 `if (count() > 0)` 守卫。alternatives.canonical 在根布局固定为
// https://www.novalon.cn(src/app/layout.tsx:87-89),metadataBase 同域(:44)。
const canonical = page.locator('link[rel="canonical"]');
if (await canonical.count() > 0) {
const href = await canonical.getAttribute('href');
expect(href).toBeTruthy();
expect(href).toMatch(/^https?:\/\/[^/]+\.cn/);
}
await expect(canonical).toHaveCount(1);
const href = await canonical.getAttribute("href");
expect(href).toBeTruthy();
expect(href).toMatch(/^https?:\/\/[^/]+\.cn/);
});
test('结构化数据存在(JSON-LD)', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
test("结构化数据存在(JSON-LD)", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
// N-27:去掉 `if (count > 0)` 守卫。根布局无条件渲染 OrganizationSchema
// (src/app/layout.tsx:192 → components/seo/structured-data),
// 站点 JSON-LD 从 DOM 消失即失败。
const jsonLdScripts = page.locator('script[type="application/ld+json"]');
await expect(jsonLdScripts.first()).toBeAttached();
const count = await jsonLdScripts.count();
expect(count).toBeGreaterThan(0);
if (count > 0) {
// 验证 JSON-LD 格式正确
for (let i = 0; i < count; i++) {
const content = await jsonLdScripts.nth(i).textContent();
expect(content).toBeTruthy();
// 验证 JSON-LD 格式正确
for (let i = 0; i < count; i++) {
const content = await jsonLdScripts.nth(i).textContent();
expect(content).toBeTruthy();
try {
JSON.parse(content!); // 应该是有效的JSON
} catch {
throw new Error(`Invalid JSON-LD at index ${i}`);
}
try {
JSON.parse(content!); // 应该是有效的JSON
} catch {
throw new Error(`Invalid JSON-LD at index ${i}`);
}
}
@@ -511,40 +584,64 @@ test.describe('SEO - 元数据完整性', { tag: '@regression' }, () => {
});
// ==================== 5. 安全性基础检查 ====================
test.describe('安全性 - 基础安全头', { tag: '@regression' }, () => {
test('安全响应头设置', async ({ page }) => {
const response = await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
if (response) {
const headers = response.headers();
// 检查常见安全头
const securityHeaders = {
'X-Content-Type-Options': headers['x-content-type-options'],
'X-Frame-Options': headers['x-frame-options'],
'X-XSS-Protection': headers['x-xss-protection'],
'Strict-Transport-Security': headers['strict-transport-security'],
'Content-Security-Policy': headers['content-security-policy'],
};
console.log('Security headers:', securityHeaders);
// 至少应该有一些安全头
const headerCount = Object.values(securityHeaders).filter(h => h).length;
expect(headerCount).toBeGreaterThanOrEqual(1);
}
});
test('没有混合内容警告', async ({ page }) => {
// 在 HTTPS 页面上检查是否有 HTTP 资源
const mixedContent = await page.evaluate(() => {
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return resources.filter(r => r.name.startsWith('http://')).map(r => r.name);
test.describe("安全性 - 基础安全头", { tag: "@regression" }, () => {
test("安全响应头设置", async ({ page }) => {
const response = await page.goto("/", {
waitUntil: "domcontentloaded",
timeout: 30000,
});
console.log('Mixed content resources:', mixedContent);
// 理想情况下不应该有混合内容
// 但由于测试环境可能使用 HTTP,这里只记录不强制断言
// N-27 修复:原 `if (response)` 守卫让全部断言在响应缺失时静默跳过。
// 改为硬断言。判据来自 next.config.mjs 的 APP_SECURITY_HEADERS(应用层注入,
// dev 与 prod harness 均生效);HSTS 由边缘层注入、本地 harness 不产生(见 N-23),
// 故不在此处断言 strict-transport-security。
if (!response) {
throw new Error("首页导航没有返回响应,无法检查安全响应头");
}
const headers = response.headers();
// 常见安全头快照(仅用于失败信息可读)
const securityHeaders = {
"X-Content-Type-Options": headers["x-content-type-options"],
"X-Frame-Options": headers["x-frame-options"],
"X-XSS-Protection": headers["x-xss-protection"],
"Content-Security-Policy": headers["content-security-policy"],
"Referrer-Policy": headers["referrer-policy"],
};
// 至少应该有一些安全头
const headerCount = Object.values(securityHeaders).filter(
(h) => h,
).length;
expect(headerCount).toBeGreaterThanOrEqual(1);
// next.config.mjs:49-67 APP_SECURITY_HEADERS 中承诺的三项硬契约
expect(headers["x-content-type-options"], JSON.stringify(securityHeaders)).toBe("nosniff");
expect(headers["x-frame-options"], JSON.stringify(securityHeaders)).toBe("DENY");
expect(
headers["content-security-policy"],
JSON.stringify(securityHeaders),
).toBeTruthy();
});
test("没有混合内容警告", async ({ page, baseURL }) => {
// N-27:原实现在末尾注明「只记录不强制断言」⇒ 本例永远不会失败。
// 该判据只在 HTTPS 页面上有意义(本站 harness 恒为 http://localhost,见 playwright.config.ts:27),
// 故显式 skip 而不是伪装通过,与 security-headers 的 HTTPS 检查同法。
test.skip(
!!baseURL && baseURL.startsWith("http://localhost"),
"本地 harness 以 http://localhost 提供站点,混合内容判据只在 HTTPS 部署上成立",
);
// 在 HTTPS 页面上检查是否有 HTTP 资源
const mixedContent = await page.evaluate(() => {
const resources = performance.getEntriesByType(
"resource",
) as PerformanceResourceTiming[];
return resources
.filter((r) => r.name.startsWith("http://"))
.map((r) => r.name);
});
expect(mixedContent).toEqual([]);
});
});
+40 -26
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
/**
* P5: 边缘情况与缺失路径 E2E 测试
@@ -42,6 +42,9 @@ test.describe('404 页面 - 未找到页面', { tag: '@regression' }, () => {
await page.goto('/missing-page', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// N-27 修复:四个推荐链接在 src/app/not-found-content.tsx:41-81 中恒定渲染,
// 原 `if (count > 0 && isVisible)` 守卫会让本例在链接缺失时静默通过。
// 改为主内容区内作用域的轮询断言——任何一条缺失/隐藏即失败。
const recommendedLinks = [
{ text: '产品', expectedPath: '/products' },
{ text: '解决方案', expectedPath: '/solutions' },
@@ -50,11 +53,9 @@ test.describe('404 页面 - 未找到页面', { tag: '@regression' }, () => {
];
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);
}
const link = page.locator(`main a:has-text("${text}")`).first();
await expect(link, `404 页缺少推荐链接「${text}」`).toBeVisible();
await expect(link).toHaveAttribute('href', expectedPath);
}
});
@@ -71,26 +72,34 @@ test.describe('404 页面 - 未找到页面', { tag: '@regression' }, () => {
test.describe('错误边界 - 应用级错误处理', { tag: '@regression' }, () => {
test('错误页面包含重试按钮', async ({ page }) => {
// 通过直接访问错误页面组件路径模拟错误状态
// 未知路径渲染 src/app/not-found-content.tsx:其恢复动作是
// 「返回上一页」<button>(not-found-content.tsx:30-33)+「返回首页」CTA。
// N-27 修复:原守卫 `if (count > 0)` ⇒ 页面空了也绿。改为硬断言两者可见。
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();
}
const backButton = page.locator('main button', { hasText: '返回上一页' }).first();
await expect(backButton).toBeVisible();
const homeLink = page.locator('main a[href="/"]').first();
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('返回首页');
}
// N-27 修复:守卫改为硬断言,并验证链接真的能回到首页
// (与 :32 的差异:此例覆盖点击后的导航行为,不只是文案存在)。
const homeLink = page.locator('main a[href="/"]').first();
await expect(homeLink).toBeVisible();
await expect(homeLink).toContainText('返回首页');
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/', { waitUntil: 'commit', timeout: 15000 }),
homeLink.click(),
]);
expect(new URL(page.url()).pathname).toBe('/');
});
});
@@ -112,16 +121,21 @@ test.describe('加载状态 - 页面过渡', { tag: '@regression' }, () => {
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);
// N-27 修复:原守卫 `if (count > 0 && isVisible)` 依赖 header 巨型下拉里的
// `a[href="/products"]`(未 hover 时隐藏 ⇒ 断言常被跳过)。
// 改用主内容区恒定渲染的叙事区行动链接(home-content-v15 NARRATIVE_ACTS 第三幕,
// p2-functional-e2e 亦以同选择器作硬断言),导航与内容都变硬断言。
const productLink = page
.locator('[data-testid="narrative-section"] a[href="/products"]')
.first();
await expect(productLink).toBeVisible();
// 导航过程中页面仍应包含某些元素
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(50);
}
await productLink.click();
await expect(page).toHaveURL((url) => url.pathname === '/products');
// 导航完成后页面仍应包含实际内容
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(50);
});
test('刷新页面后内容仍然加载', async ({ page }) => {
+18 -8
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* P6: 缺失路径 E2E 测试补充
@@ -12,6 +12,10 @@ import { test, expect, type Page } from '@playwright/test';
test.setTimeout(60000);
// 本套件走桌面导航(desktop-navigation 在 md 断点以下 display:none):
// 固定桌面视口,使 chromium-mobile 项目也按用例真实语义验证引擎而非布局。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 1. 滚动进度条测试 ====================
test.describe('滚动进度条 - 全局组件行为', { tag: '@regression' }, () => {
@@ -224,16 +228,22 @@ test.describe('关键用户路径 - 首页 → 产品 → 详情', { tag: ['@smo
});
test('从产品详情页可跳转回产品列表', async ({ page }) => {
// erp 为 seed 中已发布产品(prisma/dev.db ContentItem slug='erp')。
// N-27 修复:原守卫 `if (count > 0 && isVisible)` ⇒ 返回链接消失时静默通过。
// product-detail-content-v3.tsx:97 恒定渲染 data-testid="product-detail-hero-secondary"
// 的「查看更多产品」CTA(href="/products"),以此做硬断言 + 真跳转。
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/);
}
const backLink = page.locator('[data-testid="product-detail-hero-secondary"]');
await expect(backLink).toBeVisible();
await expect(backLink).toHaveAttribute('href', '/products');
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/products', { waitUntil: 'commit', timeout: 15000 }),
backLink.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/products');
});
});
+12 -5
View File
@@ -125,12 +125,19 @@ export default defineConfig({
},
],
webServer: {
// 使用 dev server 以支持 API 路由(admin 用户旅程等测试需要)
// 若只运行非 API 测试(如 @journey @regression),可手动启动 npm run preview 并设置 reuseExistingServer: true
command: 'npm run dev',
// E2E_TARGET=production 时打构建产物(验收 A-10:dev server 下预渲染、ISR、生产响应头
// 与构建期才暴露的问题永不被测)。dev 仍是默认目标,因为多数用例依赖热更新提速。
// 产物是 output:'standalone',Next 16 明确告警 `"next start" does not work with
// "output: standalone"` ⇒ 生产目标必须直接起 dist/standalone/server.js,否则测的是不受支持的降级路径。
command:
process.env.E2E_TARGET === 'production'
? 'HOSTNAME=localhost PORT=3000 node dist/standalone/server.js'
: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120000,
// 复用已有服务会让「测的是当前代码」这一前提失效(门禁脚本曾把 next-server 留在 :3000 达
// 39 分钟),故 CI 与 production 目标一律新起服务。
reuseExistingServer: !process.env.CI && process.env.E2E_TARGET !== 'production',
timeout: 180000,
cwd: '..',
env: {
CMS_REVALIDATE_SECRET: process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret',
+30
View File
@@ -0,0 +1,30 @@
import { expect, type Locator, type Page } from '@playwright/test';
/**
* 返回当前视口下真正可用的一级导航容器(桌面顶栏 / 已展开的移动抽屉)。
*
* 桌面 project 的 `desktop-navigation` 是 SSR 直出的 `<a>`,无需 hydration;移动 project 的
* 抽屉只在 `isOpen` 时挂载,而按钮 `onClick` 要等 React hydration 才生效,首屏
* `waitUntil: 'domcontentloaded'` 之后的首击可能被吞(全量并发跑时 chromium-mobile 实测)。
* 这里按「未可见才点击」幂等地重试,重复点击不会把抽屉关掉。
*
* 返回容器而非链接列表:桌面导航里「产品 / 解决方案」是下拉触发器,文案不一定落在 `<a>` 上。
*/
export async function primaryNav(page: Page): Promise<Locator> {
const desktopNav = page.getByTestId('desktop-navigation');
if (await desktopNav.isVisible()) {
return desktopNav;
}
const button = page.getByTestId('mobile-menu-button');
const drawer = page.getByTestId('mobile-navigation');
await expect(async () => {
if (!(await drawer.isVisible())) {
await button.click();
}
await expect(drawer).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
await expect(drawer.locator('a').first()).toBeVisible({ timeout: 5000 });
return drawer;
}
+27 -43
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
/**
* 安全测试套件
@@ -110,21 +110,20 @@ test.describe('表单安全 - 联系表单验证', { tag: '@security' }, () => {
const formAction = await form.getAttribute('action');
console.log(` Form method: ${formMethod}, action: ${formAction}`);
// 表单可以没有 method 属性(默认 GET),或使用 onSubmit 处理
// 通过拦截 API 请求验证最终提交使用 POST 方法
// 使用 page.route 拦截 API 请求以捕获请求方法
// N-27 修复:原实现把「拦截成功」与「5s 超时」并列为两条通过路径
//(超时分支只查字段可见性)⇒ 表单不再 POST /api/contact 也绿。
// 真实契约:contact-content-v3.tsx:181-182 校验通过后恒定
// fetch('/api/contact', { method: 'POST' })。本例填入的全是合法值
//(zod 校验可通过),因此拦截必须发生且方法必须是 POST,否则变红。
let capturedMethod = '';
const routePromise = new Promise<void>((resolve) => {
page.route('**/api/contact', async (route) => {
capturedMethod = route.request().method();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: 'true' }),
});
resolve();
await page.route('**/api/contact', async (route) => {
capturedMethod = route.request().method();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
}).catch(() => null);
});
// 填充表单并提交
await page.locator('[data-testid="name-input"]').fill('测试用户');
@@ -142,30 +141,15 @@ test.describe('表单安全 - 联系表单验证', { tag: '@security' }, () => {
// 提交表单
await page.locator('[data-testid="submit-button"]').click();
// 等待路由拦截完成或超时
const routeResult = await Promise.race([
routePromise.then(() => 'intercepted' as const),
page.waitForTimeout(5000).then(() => 'timeout' as const),
]);
if (routeResult === 'intercepted') {
expect(capturedMethod).toBe('POST');
console.log(' ✅ 表单提交使用 POST 方法');
} else {
// 如果 API 路由未触发,可能被客户端验证拦截
console.log(' ⚠️ API 路由未触发,检查客户端验证结果');
const bodyText = await page.locator('body').textContent();
const hasSuccessIndicator =
bodyText!.includes('消息已发送') ||
bodyText!.includes('感谢') ||
bodyText!.includes('发送成功');
if (hasSuccessIndicator) {
console.log(' ✅ 表单提交成功(客户端验证通过)');
}
// 验证表单字段存在且可交互
await expect(page.locator('[data-testid="name-input"]')).toBeVisible();
await expect(page.locator('[data-testid="submit-button"]')).toBeVisible();
}
// 等待拦截发生(10s 内未发生 ⇒ 红),随后硬断言方法为 POST
await expect
.poll(() => capturedMethod, {
timeout: 10000,
message: '提交后 10s 内未拦截到 /api/contact 请求(表单不再向该端点提交?)',
})
.not.toBe('');
expect(capturedMethod).toBe('POST');
console.log(' ✅ 表单提交使用 POST 方法');
});
test('输入字段包含验证属性', async ({ page }) => {
@@ -364,12 +348,12 @@ test.describe('安全传输 - 资源加载', { tag: '@security' }, () => {
test('静态资源通过 HTTPS 加载', async ({ page, baseURL }) => {
// 本地开发环境使用 HTTP,跳过此测试
// 生产环境(HTTPS)下应确保所有资源通过 HTTPS 加载
if (baseURL && baseURL.startsWith('http://localhost')) {
console.log(' ⏭️ 本地开发环境(HTTP),跳过 HTTPS 资源检查');
return;
}
test.skip(
!!baseURL && baseURL.startsWith('http://localhost'),
'本地 harness 恒以 http://localhost 提供站点(playwright.config.ts:27),HTTPS-only 断言只能在真实 HTTPS 部署上执行(N-17)'
);
await page.goto('/', { waitUntil: 'networkidle', timeout: 30000 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 检查页面中所有资源链接
+78
View File
@@ -0,0 +1,78 @@
import type { Page } from '@playwright/test';
/**
* 移动端触摸目标扫描(验收 R-4 / E2E 归零)。
*
* 标准出处(两条独立信源):
* · WCAG 2.2 SC 2.5.8 Target Size (Minimum) —— Level AA,24×24 CSS px,
* 例外:Equivalent / Inline / User Agent Control / Essential / Spacing。
* · WCAG 2.1 SC 2.5.5 Target Size (Enhanced) —— Level AAA,44×44 CSS px。
* 旧断言把 44px 写成「WCAG 2.1 AA」是标准错配:44px 属 AAA,AA 档没有触摸目标条款。
* 因此这里以 AA(24px) 为硬门禁、AAA(44px) 为 advisory 清单供设计侧跟踪。
*/
export interface TouchTarget {
tag: string;
text: string;
width: number;
height: number;
}
export interface TouchTargetReport {
/** 违反 AA 2.5.8(<24×24)的目标,硬门禁应为 0 */
aaViolations: TouchTarget[];
/** 未达 AAA 2.5.5(<44×44)的目标,仅作跟踪日志 */
aaaShortlist: TouchTarget[];
}
const AA_MIN_PX = 24;
const AAA_MIN_PX = 44;
export async function scanTouchTargets(page: Page): Promise<TouchTargetReport> {
return page.evaluate(([aaMin, aaaMin]) => {
const nodes = document.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
);
const aaViolations: TouchTarget[] = [];
const aaaShortlist: TouchTarget[] = [];
nodes.forEach((el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const rendered =
rect.width > 0 &&
rect.height > 0 &&
rect.right > 0 &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
!el.hasAttribute('hidden');
if (!rendered) {return;}
// Inline 例外(2.5.8):段落/列表/标题内的行内链接不按独立目标计量。
if (el.closest('p, li, h1, h2, h3, h4, h5, h6') && style.display === 'inline') {return;}
const entry: TouchTarget = {
tag: el.tagName,
text: (el.textContent || '').trim().slice(0, 30) || el.tagName,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
if (rect.width < aaMin || rect.height < aaMin) {
aaViolations.push(entry);
} else if (rect.width < aaaMin || rect.height < aaaMin) {
aaaShortlist.push(entry);
}
});
return { aaViolations, aaaShortlist };
}, [AA_MIN_PX, AAA_MIN_PX]);
}
export function formatTargets(targets: TouchTarget[]): string {
return targets
.slice(0, 12)
.map((t) => ` <${t.tag}> "${t.text}" — ${t.width}x${t.height}px`)
.join('\n');
}
+12 -7
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* UJ-08: 服务探索者旅程
@@ -9,6 +9,10 @@ import { test, expect, type Page } from '@playwright/test';
test.setTimeout(90000);
// 本套件走桌面导航(desktop-navigation 在 md 断点以下 display:none):
// 固定桌面视口,使 chromium-mobile 项目也按用例真实语义验证引擎而非布局。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 辅助函数 ====================
/**
@@ -124,14 +128,15 @@ test.describe('UJ-08: 服务探索者旅程(服务列表 → 服务详情 →
break;
}
}
// 服务详情页的信任区域可能为"查看案例"链接而非文本块,软验证
// 服务详情页的信任区域可能为"查看案例"链接而非文本块。
// N-27 修复:原 `isVisible().catch(() => false)` 把定位器/基础设施异常吞成
// 「链接不存在」,回退校验只在异常路径之外才被读到 ⇒ 改为无条件执行路径上的
// 硬断言:链接消失 ⇒ toBeVisible 超时判红;基础设施异常 ⇒ 原样抛出,不再伪装。
if (!hasTrustSection) {
const caseLink = page.locator('a:has-text("查看案例")').first();
const hasCaseLink = await caseLink.isVisible().catch(() => false);
if (hasCaseLink) {
hasTrustSection = true;
console.log('UJ-08: 找到"查看案例"链接作为信任证明');
}
await expect(caseLink, '服务详情页信任区域应含文本关键词或"查看案例"链接').toBeVisible({ timeout: 5000 });
hasTrustSection = true;
console.log('UJ-08: 找到"查看案例"链接作为信任证明');
}
expect(hasTrustSection).toBe(true);
+5 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* UJ-09: 产品深度浏览旅程
@@ -9,6 +9,10 @@ import { test, expect, type Page } from '@playwright/test';
test.setTimeout(90000);
// 本套件走桌面导航(desktop-navigation 在 md 断点以下 display:none):
// 固定桌面视口,使 chromium-mobile 项目也按用例真实语义验证引擎而非布局。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 辅助函数 ====================
/**
+23 -15
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* UJ-11: 首页转化旅程(设计优化专项)
@@ -114,18 +114,18 @@ test.describe('UJ-11b: 信任验证旅程(共创标签 → 转化)', () => {
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 首批客户共创横幅存在(无虚构客户,如实标注)
const earlyAccessBanner = page.locator('[data-testid="early-access-banner"]').first();
await earlyAccessBanner.scrollIntoViewIfNeeded();
await expect(earlyAccessBanner).toBeVisible({ timeout: 10000 });
// 验收 E2E 修订:early-access-banner 已在复评 P1 中删除——共创主张曾三处重复
// (横幅 / 案例深色卡 / 洞察卡),收敛为案例区深色共创卡唯一载体,
// 由 home-content-v15.test.tsx「consolidates the co-creation pitch」钉住。
// 这里改断言存活的那一处,保持「共创标签 → /contact 转化」的旅程语义。
const coCreationCta = page.locator('[data-testid="cases-co-creation-cta"]').first();
await coCreationCta.scrollIntoViewIfNeeded();
await expect(coCreationCta).toBeVisible({ timeout: 10000 });
await expect(coCreationCta).toHaveAttribute('href', '/contact');
const bannerText = await earlyAccessBanner.textContent();
expect(bannerText).toContain('首批客户共创中');
// 引导至联系转化
const cta = earlyAccessBanner.locator('a[href="/contact"]').first();
await expect(cta).toBeVisible();
await expect(cta).toHaveAttribute('href', '/contact');
const cardText = await coCreationCta.locator('xpath=ancestor::section[1]').textContent();
// 文案由 CMS pageCopy 可覆盖,故断言主题词而非整句措辞
expect(cardText).toContain('共创');
});
test('@journey @critical 空案例区呈现共创内容块而非裸空态', async ({ page }) => {
@@ -138,8 +138,13 @@ test.describe('UJ-11b: 信任验证旅程(共创标签 → 转化)', () => {
expect(bodyText).not.toContain('暂无案例数据');
// 出现「共创进行时」前瞻内容块
expect(bodyText).toContain('共创进行时');
// 共创块包含转化 CTA
expect(bodyText).toContain('成为首批共创客户');
// 共创块包含转化 CTA。
// 验收 E2E 修订:原断言的「成为首批共创客户」从未落地为组件文案——
// 预约动作文案由 CTA_LABELS.consult 单一真源约束为「预约咨询」
// (src/lib/constants/cta-labels.ts + DESIGN.md),原测试把设计意图当成了实现。
const coCreationCta = page.locator('[data-testid="cases-co-creation-cta"]').first();
await expect(coCreationCta).toHaveAttribute('href', '/contact');
await expect(coCreationCta).toContainText('预约咨询');
});
});
@@ -170,8 +175,10 @@ test.describe('UJ-11c: 移动端首屏转化旅程', () => {
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 信任层
// 信任层:dev 服务高负载下区块节点会被替换,直接 scrollIntoViewIfNeeded 会撞上
// 「Element is not attached to the DOM」(chain3 实测 firefox),先等可见再滚动。
const trustSection = page.locator('[data-testid="trust-section"]').first();
await expect(trustSection).toBeVisible({ timeout: 15000 });
await trustSection.scrollIntoViewIfNeeded();
await expect(trustSection).toBeVisible();
const trustText = await trustSection.textContent();
@@ -179,6 +186,7 @@ test.describe('UJ-11c: 移动端首屏转化旅程', () => {
// 叙事区
const narrativeSection = page.locator('[data-testid="narrative-section"]').first();
await expect(narrativeSection).toBeVisible({ timeout: 15000 });
await narrativeSection.scrollIntoViewIfNeeded();
await expect(narrativeSection).toBeVisible();
expect(await narrativeSection.textContent()).toContain('01');
+152 -228
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
import { test, expect, type Page, type APIRequestContext } from './fixtures';
/**
* UJ: 用户旅程 E2E 测试
@@ -16,6 +16,10 @@ import { test, expect, type Page, type APIRequestContext } from '@playwright/tes
test.setTimeout(90000);
// 本套件为桌面旅程(desktop-navigation 在 md 断点以下 display:none),
// 移动端等价旅程由 mobile-user-journeys.spec.ts 覆盖。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 辅助函数 ====================
/**
@@ -323,145 +327,97 @@ test.describe('UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
// 验证新闻列表存在文章条目
const newsListItems = page.locator('a[href*="/news/"]').first();
await expect(newsListItems).toBeVisible({ timeout: 5000 });
// N-27 修复:种子含 2 篇已发布新闻(company-founded、
// digital-transformation-solution),列表卡片链接 a[href*="/news/"] 恒存在。
// 原实现用 `if (articleCount > 0)` 包住整段旅程 + else 兜底 bodyText —— 新闻
// 列表消失时本例仍绿。改为硬断言,任何一步元素消失即红。
const firstArticle = page.locator('a[href*="/news/"]').first();
await expect(firstArticle).toBeVisible({ timeout: 5000 });
// 获取新闻列表中的文章链接数量
const articleLinks = page.locator('a[href*="/news/"]');
const articleCount = await articleLinks.count();
// 可能有些链接是导航/面包屑类,应该有至少1个文章链接
console.log('UJ-04 article links found:', articleCount);
// === Step 2: 点击第一篇新闻进入详情页 ===
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
if (articleCount > 0) {
// === Step 2: 点击第一篇新闻进入详情页 ===
const firstArticle = articleLinks.first();
await firstArticle.click();
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证新闻详情页有正文内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// === Step 4: 通过面包屑返回新闻列表 ===
// news-detail-content-v3.tsx:68-71 恒渲染 breadcrumb nav 中的「新闻动态」链接
const breadcrumbNewsLink = page.locator('nav[aria-label="breadcrumb"] a[href="/news"]');
await expect(breadcrumbNewsLink).toBeVisible();
await Promise.all([
page.waitForURL(/\/news\/?$/, { timeout: 15000, waitUntil: 'commit' }),
breadcrumbNewsLink.click(),
]);
// 验证新闻详情页有发布时间或内容区域
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 4: 返回新闻列表 ===
// 通过面包屑导航返回
const breadcrumbNewsLink = page.locator('nav a[href="/news"], [aria-label*="新闻"] a, a:has-text("新闻")').first();
if (await breadcrumbNewsLink.count() > 0 && await breadcrumbNewsLink.isVisible()) {
await breadcrumbNewsLink.click();
await page.waitForURL(/\/news\/?$/, { timeout: 10000 });
await page.waitForTimeout(1000);
} else {
// 直接导航回新闻列表
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
}
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
} else {
// 没有文章链接时,验证页面内容存在
console.log('UJ-04: No article links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
});
});
// ==================== UJ-05: 案例浏览者筛选旅程 ====================
test.describe('UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@journey @regression 客户从案例列表筛选并查看详情', async ({ page }) => {
// === Step 1: 访问案例列表 ===
test('@journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => {
// N-27 修复:原实现用 `if (caseLinkCount > 0)` 包住详情断言 + else 兜底
// bodyText ⇒ 案例区永远空白也绿。种子刻意 0 条案例(prisma/seed.ts:628-631
// 「首批客户共创阶段,案例种子为空」),cases-content-v3.tsx:234-343 的契约是
// 「hasAnyCases=false ⇒ 渲染共创空态卡 + 行业筛选器整段缺席」。
// 「列表→筛选→详情」的真断言需要 ≥1 条已发布案例,接通前不可验证(见整改报告);
// 本例改为断言当下真实的转化旅程:案例页 → 空态出口「预约咨询」→ 联系页。
await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证案例列表页加载成功
// 页面骨架正常:hero h1 恒定渲染(cases-content-v3.tsx:142-163)
const casesTitle = page.locator('h1').first();
await expect(casesTitle).toBeVisible({ timeout: 10000 });
const casesTitleText = await casesTitle.textContent();
expect(casesTitleText).toBeTruthy();
console.log('UJ-05 cases page title:', casesTitleText);
await expect(casesTitle).toContainText('以成果为目标');
// === Step 2: 尝试使用行业筛选(如果有筛选功能) ===
const industryFilters = page.locator(
'button:has-text("全部"), button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), button:has-text("教育"), ' +
'button:has-text("科技"), [data-testid*="filter"], [data-testid*="industry"]'
);
// 零案例契约:筛选按钮组与案例卡片必须不存在(种子加入案例后此处变红,
// 提示把「列表→筛选→详情」断言接回来,而非静默跳过)
await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
if (await industryFilters.count() > 0) {
// 点击第一个可见的筛选按钮(非"全部")
const filterButtons = page.locator(
'button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), ' +
'button:has-text("科技")'
);
const filterCount = await filterButtons.count();
if (filterCount > 0) {
const firstFilter = filterButtons.first();
const filterText = await firstFilter.textContent();
console.log('UJ-05 clicking filter:', filterText);
await firstFilter.click();
await page.waitForTimeout(1500);
}
}
// 空态转化卡:预约咨询 CTA 存在且 href 契约正确
const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]');
await expect(emptyPrimaryCta).toBeVisible();
await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact');
// === Step 3: 点击案例进入详情页 ===
const caseLinks = page.locator('a[href*="/cases/"]');
const caseLinkCount = await caseLinks.count();
console.log('UJ-05 case links found:', caseLinkCount);
// === 点击转化出口,验证抵达联系页 ===
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/contact', { timeout: 15000, waitUntil: 'commit' }),
emptyPrimaryCta.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/contact');
if (caseLinkCount > 0) {
const firstCase = caseLinks.first();
await firstCase.click();
await page.waitForURL(/\/cases\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 4: 验证案例详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 验证案例详情页包含内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证包含案例相关标识
const hasCaseIndicator = detailText!.includes('案例') ||
detailText!.includes('成果') ||
detailText!.includes('挑战') ||
detailText!.includes('解决方案');
expect(hasCaseIndicator).toBe(true);
} else {
// 没有案例链接时,验证页面内容存在
console.log('UJ-05: No case links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
const contactTitle = page.locator('h1').first();
await expect(contactTitle).toBeVisible({ timeout: 10000 });
const contactText = await page.locator('main').first().textContent();
expect(contactText!.length).toBeGreaterThan(50);
});
});
// ==================== UJ-10: 深度搜索者旅程 ====================
test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// === Step 1: 访问新闻列表 ===
// N-27 修复:原实现用 `if (isVisible)`/`if (count > 0)` 包住 Step 2/3/4/5/6
// 几乎所有断言 ⇒ 分类按钮或文章消失时本例只剩两条哨兵断言仍绿。
// 种子契约(prisma/dev.db):恰有 2 篇已发布新闻,各属一类——
// company-founded(公司新闻)、digital-transformation-solution(研发动态);
// 分类按钮组恒渲染(news-content-v3.tsx:159-167)⇒ 全部改为硬断言。
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
@@ -471,137 +427,83 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
await expect(newsTitle).toBeVisible({ timeout: 10000 });
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
console.log('UJ-10 news title:', newsTitleText);
// 验证分类筛选按钮存在
const categoryButtons = page.locator('button:has-text("公司新闻"), button:has-text("研发动态")');
const categoryCount = await categoryButtons.count();
console.log('UJ-10 category buttons found:', categoryCount);
expect(categoryCount).toBeGreaterThan(0);
// 验证分类筛选按钮存在(两个分类 chip 必须可见)
const companyNewsBtn = page.locator('button', { hasText: '公司新闻' }).first();
const devNewsBtn = page.locator('button', { hasText: '研发动态' }).first();
await expect(companyNewsBtn).toBeVisible();
await expect(devNewsBtn).toBeVisible();
// === Step 2: 点击"公司新闻"分类筛选 ===
const companyNewsBtn = page.locator('button:has-text("公司新闻")').first();
if (await companyNewsBtn.isVisible()) {
await companyNewsBtn.click();
await page.waitForTimeout(1000);
// 验证筛选后文章列表更新
const filteredArticles = page.locator('a[href*="/news/"]');
const filteredCount = await filteredArticles.count();
console.log('UJ-10 company news articles:', filteredCount);
}
// === Step 2: 点击「公司新闻」→ 列表应收窄到恰 1 篇 ===
await companyNewsBtn.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
if (await firstArticle.count() > 0 && await firstArticle.isVisible()) {
const firstArticleHref = await firstArticle.getAttribute('href');
console.log('UJ-10 reading first article:', firstArticleHref);
await firstArticle.click();
await expect(firstArticle).toContainText('正式成立');
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证新闻详情页加载成功(标题/分类徽标/正文都是恒定渲染)
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toContainText('四川睿新致远科技有限公司正式成立');
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
await expect(detailContent).toContainText('公司新闻');
// 验证新闻详情页加载成功
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 first article title:', detailTitleText);
// 相关内容区:两篇新闻分属不同分类,相关新闻按同分类筛选
//(news/[slug]/page.tsx:43-46)⇒ 当前种子下恒为空,section 必须缺席
await expect(page.getByRole('heading', { name: '相关新闻' })).toHaveCount(0);
// 验证详情页有正文内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 软验证:页面包含发布日期(非关键断言,不阻止测试通过)
const dateIndicator = page.locator('time, [datetime], [aria-label*="date"]').first();
const hasDateElement = await dateIndicator.count() > 0;
if (hasDateElement) {
await expect(dateIndicator).toBeVisible();
}
// 验证内容发现:相关新闻或推荐阅读区域
const relatedSection = page.locator(
'text=/相关新闻|推荐阅读|相关文章|RELATED|more news/i'
).first();
if (await relatedSection.count() > 0) {
await expect(relatedSection).toBeVisible();
console.log('UJ-10 related news section found');
}
}
// === Step 4: 返回新闻列表,切换分类浏览 ===
// === Step 4: 返回新闻列表,切换「研发动态」分类 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 切换到"研发动态"分类
const devNewsBtn = page.locator('button:has-text("研发动态")').first();
if (await devNewsBtn.isVisible()) {
await devNewsBtn.click();
await page.waitForTimeout(1000);
}
const devNewsBtn2 = page.locator('button', { hasText: '研发动态' }).first();
await expect(devNewsBtn2).toBeVisible();
await devNewsBtn2.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 5: 阅读第二篇新闻文章 ===
const secondArticle = page.locator('a[href*="/news/"]').first();
if (await secondArticle.count() > 0 && await secondArticle.isVisible()) {
const secondArticleHref = await secondArticle.getAttribute('href');
console.log('UJ-10 reading second article:', secondArticleHref);
await secondArticle.click();
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
secondArticle.click(),
]);
await page.waitForTimeout(1000);
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证第二篇新闻详情页
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toBeVisible({ timeout: 10000 });
const secondDetailTitleText = await secondDetailTitle.textContent();
expect(secondDetailTitleText).toBeTruthy();
expect(secondDetailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 second article title:', secondDetailTitleText);
// 验证详情页有正文内容
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
}
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toContainText('数字化转型');
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
await expect(secondDetailContent).toContainText('研发动态');
// === Step 6: 返回全部新闻列表,验证内容发现能力 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 点击"全部"恢复显示所有新闻
const allBtn = page.locator('button:has-text("全部")').first();
if (await allBtn.isVisible()) {
await allBtn.click();
await page.waitForTimeout(1000);
}
// 点击「全部」恢复显示所有新闻
const allBtn = page.locator('button', { hasText: '全部' }).first();
await expect(allBtn).toBeVisible();
await allBtn.click();
// 验证所有新闻文章显示
const allArticles = page.locator('a[href*="/news/"]');
const allCount = await allArticles.count();
console.log('UJ-10 all articles count:', allCount);
expect(allCount).toBeGreaterThanOrEqual(1);
// 全部种子新闻文章可见(恰 2 篇)
await expect(page.locator('a[href*="/news/"]')).toHaveCount(2);
// 验证搜索功能(内容发现工具)
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill('数字化');
await page.waitForTimeout(1000);
const searchResults = page.locator('a[href*="/news/"]');
const searchResultCount = await searchResults.count();
console.log('UJ-10 search results:', searchResultCount);
// 搜索功能正常工作,返回结果
expect(searchResultCount).toBeGreaterThanOrEqual(0);
}
// 最终验证:页面加载正常,内容可发现
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 completed successfully');
// 验证搜索功能(内容发现工具):输入命中词应精确收窄到 1 篇
const searchInput = page.getByLabel('搜索新闻');
await expect(searchInput).toBeVisible();
await searchInput.fill('数字化转型');
const searchResults = page.locator('a[href*="/news/"]');
await expect(searchResults).toHaveCount(1);
await expect(searchResults.first()).toContainText('数字化转型');
});
});
@@ -949,16 +851,38 @@ test.describe('UJ-07: 媒体管理员上传旅程', () => {
const mediaItems = mediaData.items || [];
console.log('UJ-07 existing media items:', mediaItems.length);
// === Step 4: 上传测试文件(通过 API POST /api/admin/media 带 FormData) ===
const testFileName = `uj07-test-${Date.now()}.txt`;
const testFileContent = 'UJ-07 test file content';
// === Step 4: 验收 A-4 契约 —— 非白名单扩展名即使谎报 MIME 也必须拒绝 ===
// 原实现在此处上传 .txt 并断言 200:那是 A-4 修复前「任意文件可落盘」的行为,
// 断言的正是漏洞本身,故改为断言拒绝 + 白名单内真实图片成功。
const rejectedResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: `uj07-rejected-${Date.now()}.txt`,
mimeType: 'image/png',
buffer: Buffer.from('UJ-07 disallowed extension probe', 'utf-8'),
},
},
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(rejectedResponse.status()).toBe(400);
console.log('UJ-07 .txt upload rejected as expected');
// === Step 4b: 白名单内的真实图片上传 ===
// 96 字节 8×8 PNG,由 sharp 生成并回读校验(meta.format === 'png'),非手造字节
const pngBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQYlWM4Imf1Hx9mGBkKAGZzhsHMOkj6AAAAAElFTkSuQmCC',
'base64'
);
const testFileName = `uj07-test-${Date.now()}.png`;
const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: testFileName,
mimeType: 'text/plain',
buffer: Buffer.from(testFileContent, 'utf-8'),
mimeType: 'image/png',
buffer: pngBuffer,
},
},
headers: {
+118 -47
View File
@@ -1,13 +1,37 @@
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page } from './fixtures';
test.describe.configure({ mode: 'parallel' });
// dev 指示器 <nextjs-portal> 的剥离已上移到 e2e/fixtures.ts 的 context 夹具,全站所有 project 共用。
// 基线口径固定为「用户已表达 Cookie 偏好」。同意条由 2s 定时器后才渲染,慢引擎(firefox / webkit)
// 会在截图前把它画进首屏底部,快引擎(chromium)则赶不上 —— 这是与引擎速度相关的竞态,
// 且这两个 project 为规避 newContext 兼容噪声而使用空 storageState(见 5a4d310),拿不到预置偏好。
// 时间戳取常量而非 Date.now(),避免基线随采集日漂移。
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'novalon-cookie-preferences',
JSON.stringify({
necessary: true,
analytics: false,
marketing: false,
functionality: true,
timestamp: 1784284000000,
}),
);
});
});
const VISUAL_TEST_PAGES = [
{ path: '/', name: 'home', label: '首页' },
{ path: '/about', name: 'about', label: '关于我们' },
{ path: '/contact', name: 'contact', label: '联系我们' },
{ path: '/products', name: 'products', label: '产品中心' },
{ path: '/products/erp', name: 'product-erp', label: '产品详情-ERP' },
// 与 /products/erp 是不同路由:此前只在 Lighthouse URL 表里,视觉表缺它 ⇒ 同型覆盖缺口(验收 §8 限制⑦)
{ path: '/products/erp-upgrade', name: 'product-erp-upgrade', label: '产品 ERP 升级页' },
{ path: '/about/brand', name: 'about-brand', label: '品牌资产页' },
{ path: '/solutions', name: 'solutions', label: '解决方案列表' },
{ path: '/solutions/manufacturing', name: 'solution-manufacturing', label: '解决方案详情-制造' },
{ path: '/services', name: 'services', label: '服务列表' },
@@ -69,18 +93,31 @@ test.describe('L2: 组件视觉状态测试', () => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const primaryBtn = page.locator('button, a[role="button"]').first();
if (await primaryBtn.count() > 0 && await primaryBtn.isVisible()) {
await expect(primaryBtn).toHaveScreenshot('button-default.png');
// N-15(c) 诊断结论:**装配缺陷,不是产品缺陷**。
// 旧选择器 `button, a[role="button"]` 的 .first() 取的是 DOM 序第一个 <button>,即桌面导航的
// 「产品」下拉触发器;它的祖先 <nav class="hidden md:flex …" data-testid="desktop-navigation">
// (src/components/layout/header.tsx:124)在 md(768px) 以下 display:none ⇒ 按设计不可见。
// 实测(同一 fixture:reducedMotion=reduce + 已同意 Cookie + 剥离 nextjs-portal,390x844 iPhone14):
// 匹配总数 4、其中可见 1;.first().isVisible() = false;
// 前 3 个匹配(产品/解决方案下拉 + header.tsx:169 那个 `hidden md:flex` 容器里的主题切换按钮)
// 的祖先链里各有一个 display:none 容器。
// 390px 下页面真正提供的按钮是 src/components/layout/header.tsx:184-196 的
// data-testid="mobile-menu-button"(aria-label="打开菜单"、style minWidth/minHeight 44px),
// 实测盒 44x44 @ (338,10) ⇒ 触控目标达标,导航入口存在 ⇒ 产品侧无缺陷。
// 修法:用 Playwright 的 :visible 伪类过滤,让每个 project 拍到「该视口下第一个真实可见的按钮」。
// 桌面/平板档目标元素不变(那里 .first() 本来就是可见节点,过滤不改变选择结果):
// 实测 1280px 下新选择器盒 = 81x43.98,与现存基线 button-default-visual-chromium-desktop.png 的 81x44 一致。
const primaryBtn = page.locator('button:visible, a[role="button"]:visible').first();
await expect(primaryBtn).toBeVisible();
await expect(primaryBtn).toHaveScreenshot('button-default.png');
await primaryBtn.hover();
await page.waitForTimeout(300);
await expect(primaryBtn).toHaveScreenshot('button-hover.png');
await primaryBtn.hover();
await page.waitForTimeout(300);
await expect(primaryBtn).toHaveScreenshot('button-hover.png');
await primaryBtn.focus();
await page.waitForTimeout(200);
await expect(primaryBtn).toHaveScreenshot('button-focus.png');
}
await primaryBtn.focus();
await page.waitForTimeout(200);
await expect(primaryBtn).toHaveScreenshot('button-focus.png');
});
test('导航菜单 - 桌面端视觉', async ({ page }) => {
@@ -88,9 +125,8 @@ test.describe('L2: 组件视觉状态测试', () => {
await waitForPageStable(page);
const header = page.locator('header').first();
if (await header.isVisible()) {
await expect(header).toHaveScreenshot('header-navigation.png');
}
await expect(header).toBeVisible();
await expect(header).toHaveScreenshot('header-navigation.png');
});
test('页脚区域 - 视觉一致性', async ({ page }) => {
@@ -98,19 +134,28 @@ test.describe('L2: 组件视觉状态测试', () => {
await waitForPageStable(page);
const footer = page.locator('footer').first();
if (await footer.isVisible()) {
await expect(footer).toHaveScreenshot('footer-section.png');
}
await expect(footer).toBeVisible();
await expect(footer).toHaveScreenshot('footer-section.png');
});
test('卡片组件 - 视觉样式', async ({ page }) => {
await page.goto('/products', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const card = page.locator('[class*="card"]').first();
if (await card.count() > 0 && await card.isVisible()) {
await expect(card).toHaveScreenshot('product-card.png');
}
// N-15(b) 修正:旧选择器 [class*="card"] 在 /products 上匹配 **0 个元素**(实测 1280/834/390 三档
// 视口 count 均为 0)——本页卡片由 BentoItem 渲染,class 只有 Tailwind 的形状/颜色描述
// (`h-full md:col-span-2 lg:col-span-2 …`),不含 "card" 子串 ⇒ 用例在截图之前就死在
// `element(s) not found` 上,5/5 project 恒红,且 product-card.png 从未存在。
// 真实 DOM 的语义锚点:src/components/sections/bento-grid.tsx:48 输出 data-testid,
// 值由 src/app/(marketing)/products/products-content-v3.tsx:105 的
// `bento-product-card-${product.id}` 传入 ⇒ 实测 7 个匹配,首个为 bento-product-card-erp,
// 三档视口均 isVisible()=true(1280px 盒 594.5x791.8)。
const card = page.locator('[data-testid^="bento-product-card-"]').first();
await expect(card).toBeVisible();
// 钉住断言对象确实是 Bento 卡片单元(role="listitem" 的容器),防止选择器再次漂回空匹配
await expect(card).toHaveAttribute('role', 'listitem');
await expect(card).toHaveAttribute('data-bento-size', /.+/);
await expect(card).toHaveScreenshot('product-card.png');
});
test('创始团队观点 - 桌面端视觉', async ({ page }) => {
@@ -126,14 +171,37 @@ test.describe('L2: 组件视觉状态测试', () => {
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
await waitForPageStable(page);
const input = page.locator('input[type="text"], input[type="email"]').first();
if (await input.count() > 0 && await input.isVisible()) {
await expect(input).toHaveScreenshot('input-default.png');
// N-15(b) 修正:/contact 上 DOM 序第一个 input[type="text"] 是**反垃圾蜜罐**,它按设计隐藏
// (src/app/(marketing)/contact/contact-content-v3.tsx:431-439:
// style={{ display: 'none' }} + tabIndex={-1} + autoComplete="off" + aria-hidden="true"
// + aria-label="honeypot" + name="website")。
// 旧选择器 `input[type="text"], input[type="email"]` 的 .first() 恰好命中它
// (实测 name="website"、isVisible()=false,5/5 project 恒红,报 Expected: visible / Received: hidden)
// ⇒ 断言对象是一个故意不可见的安全控件,失败是测试错而不是页面坏。
// 蜜罐覆盖**不删**:改为在此单独、显式地断言它的存在与隐藏属性(这才是它真正的契约)。
const honeypot = page.locator('form input[name="website"]');
await expect(honeypot).toHaveCount(1);
await expect(honeypot).toHaveAttribute('type', 'text');
await expect(honeypot).toHaveAttribute('tabindex', '-1');
await expect(honeypot).toHaveAttribute('autocomplete', 'off');
await expect(honeypot).toHaveAttribute('aria-hidden', 'true');
await expect(honeypot).toHaveAttribute('aria-label', 'honeypot');
await expect(honeypot).toBeHidden();
await input.click();
await page.waitForTimeout(200);
await expect(input).toHaveScreenshot('input-focused.png');
}
// 可见字段:限定在表单内并用 Playwright 的 :visible 过滤掉蜜罐。
// 实测(1280/834/390 三档一致):form input 共 5 个、可见 4 个,
// 第一个可见者 = <input id="name" name="name" data-testid="name-input">(真实姓名框,
// 由 src/components/ui/input.tsx:29 的 {...props} 透传 testid),蜜罐被自然排除。
const input = page.locator('form input:visible').first();
// 回归护栏:选中的必须是带 name 的真实字段,永不再漂回蜜罐
await expect(input).toHaveAttribute('name', /^(name|email|phone|subject)$/);
await expect(input).not.toHaveAttribute('aria-hidden', 'true');
await expect(input).toBeVisible();
await expect(input).toHaveScreenshot('input-default.png');
await input.click();
await page.waitForTimeout(200);
await expect(input).toHaveScreenshot('input-focused.png');
});
});
@@ -179,25 +247,25 @@ test.describe('L3: 排版与色彩验证', () => {
await waitForPageStable(page);
const h1 = page.locator('h1').first();
if (await h1.count() > 0 && await h1.isVisible()) {
const styles = await h1.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
fontFamily: computed.fontFamily,
color: computed.color,
lineHeight: computed.lineHeight,
};
});
await expect(h1).toBeVisible();
const styles = await h1.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
fontFamily: computed.fontFamily,
color: computed.color,
lineHeight: computed.lineHeight,
};
});
const size = parseFloat(styles.fontSize);
// Support clamp()/variable-based font sizes that may resolve differently
if (!isNaN(size)) {
expect(size).toBeGreaterThanOrEqual(16);
}
expect(styles.color || styles.fontFamily).toBeTruthy();
}
const size = parseFloat(styles.fontSize);
expect(isNaN(size)).toBe(false);
expect(size).toBeGreaterThanOrEqual(16);
expect(styles.color).toBeTruthy();
expect(styles.fontFamily).toBeTruthy();
expect(styles.fontWeight).toBeTruthy();
expect(styles.lineHeight).toBeTruthy();
});
test('品牌主色调验证', async ({ page }) => {
@@ -214,6 +282,9 @@ test.describe('L3: 排版与色彩验证', () => {
};
});
expect(primaryColor.brand || primaryColor.ink || primaryColor.bg).toBeTruthy();
// 逐个令牌断言,不用 `a || b` —— 任一令牌缺失即门失效
for (const [token, value] of Object.entries(primaryColor)) {
expect(value, `--color-${token} 未解析出值`).toBeTruthy();
}
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 KiB

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

After

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 KiB

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 882 KiB

After

Width:  |  Height:  |  Size: 880 KiB

Some files were not shown because too many files have changed in this diff Show More