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"
}
}