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:
@@ -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(','));
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user