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})`);