diff --git a/config/test/itest/apply-schema.mts b/config/test/itest/apply-schema.mts new file mode 100644 index 0000000..984505b --- /dev/null +++ b/config/test/itest/apply-schema.mts @@ -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(',')); diff --git a/config/test/itest/env.js b/config/test/itest/env.js new file mode 100644 index 0000000..4269107 --- /dev/null +++ b/config/test/itest/env.js @@ -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, +}; diff --git a/config/test/itest/global-setup.js b/config/test/itest/global-setup.js new file mode 100644 index 0000000..88faf58 --- /dev/null +++ b/config/test/itest/global-setup.js @@ -0,0 +1,122 @@ +// @ts-nocheck +/** + * 集成测试 globalSetup:创建 /tmp 一次性 SQLite 库并应用 Prisma schema。 + * + * 步骤(每一步都可审计): + * 1. 记录项目数据源(prisma/dev.db、/dev.db、/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; +}; diff --git a/config/test/itest/global-teardown.js b/config/test/itest/global-teardown.js new file mode 100644 index 0000000..c0d96b4 --- /dev/null +++ b/config/test/itest/global-teardown.js @@ -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 与运行前完全一致'); +}; diff --git a/config/test/itest/prisma-generated-transformer.js b/config/test/itest/prisma-generated-transformer.js new file mode 100644 index 0000000..2b96f38 --- /dev/null +++ b/config/test/itest/prisma-generated-transformer.js @@ -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 }; + }, +}; diff --git a/config/test/itest/setup-files.js b/config/test/itest/setup-files.js new file mode 100644 index 0000000..42b242d --- /dev/null +++ b/config/test/itest/setup-files.js @@ -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})`); diff --git a/config/test/jest.config.js b/config/test/jest.config.js index 9a4c987..775f8c0 100644 --- a/config/test/jest.config.js +++ b/config/test/jest.config.js @@ -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: ['/jest.setup.js'], testTimeout: 10000, diff --git a/config/test/jest.integration.config.js b/config/test/jest.integration.config.js new file mode 100644 index 0000000..0a03c57 --- /dev/null +++ b/config/test/jest.integration.config.js @@ -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 是 /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: ['/tests-integration'], + testMatch: ['/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: '/config/test/itest/global-setup.js', + globalTeardown: '/config/test/itest/global-teardown.js', + // 每个 worker 在 import 被测模块之前把 DATABASE_URL 钉到 /tmp 并做守卫断言 + setupFiles: ['/config/test/itest/setup-files.js'], + setupFilesAfterEnv: [], + collectCoverage: false, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleNameMapper: { + '^@/(.*)$': '/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$': '/config/test/itest/prisma-generated-transformer.js', + '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }], + }, + // SQLite 单写者 + 固定文件名:串行执行,避免多个 worker 同时写同一个临时库 + maxWorkers: 1, + testTimeout: 30000, + verbose: true, +}; diff --git a/config/test/jest.setup.js b/config/test/jest.setup.js deleted file mode 100644 index f4de7a6..0000000 --- a/config/test/jest.setup.js +++ /dev/null @@ -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); - } -}; diff --git a/config/test/lighthouserc.json b/config/test/lighthouserc.json index 592af95..153d447 100644 --- a/config/test/lighthouserc.json +++ b/config/test/lighthouserc.json @@ -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" } } diff --git a/e2e/assert-helpers.ts b/e2e/assert-helpers.ts new file mode 100644 index 0000000..32ca623 --- /dev/null +++ b/e2e/assert-helpers.ts @@ -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 { + await expect + .poll(() => target.boundingBox(), { timeout, message }) + .not.toBeNull(); + const box = await target.boundingBox(); + if (!box) { + throw new Error('boundingBox 在轮询通过后仍为 null(元素在断言瞬间消失)'); + } + return box; +} diff --git a/e2e/cases-filter.spec.ts b/e2e/cases-filter.spec.ts index f6dc833..45ce76b 100644 --- a/e2e/cases-filter.spec.ts +++ b/e2e/cases-filter.spec.ts @@ -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 在组件里是 `