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})`);
|
||||
Reference in New Issue
Block a user