Civitai 测试基础设施迁移指南:从逐文件 vi.mock 迁移到共享模块规范 Mock

发布时间:2026/9/19 5:19:32
Civitai 测试基础设施迁移指南:从逐文件 vi.mock 迁移到共享模块规范 Mock
Civitai 测试基础设施迁移指南从逐文件 vi.mock 迁移到共享模块规范 Mock【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai本文是 shared-module-mock-migration.md 的完整解读与仓库源码级展开。它聚焦于 Civitai 测试基建中一个被刻意保留下来的实战场景当你新增的测试文件直接对~/server/db/client、~/server/redis/client或~/server/logging/client调用了vi.mock触发了no-direct-shared-module-mock守卫失败时如何用 codemod 把该文件迁移到仓库既有的规范共享 Mockcanonical shared mocks之上。读完本文你将掌握codemod 的三种调用形态与安全边界、按频次排序的拒绝原因及逐一手工修复手法、~/env/server的双桶规则、两种失效断言的改写方式、以收集计数而非红绿颜色为核心的验证流程以及翻转isolate: false前的全量门槛检查。底层机制与设计哲学请先阅读姊妹篇 shared-module-mocks.md。⚠️ 阅读前提务必先读2026-08-16 起本迁移所服务的批量迁移已暂停。它本是为了让isolate: false在整套测试中安全落地但实测收益呈凸性——迁移 43% 的文件只换来 3% 的导入节省100% 迁移才拿到 66%而全量迁移需要横跨 354 个 specifier、改造 601 个文件unit-fast#3975最终被关闭而非合并。然而本文的配方对仍然存在的唯一场景依然准确且有效当no-direct-shared-module-mock守卫因你新增的直接vi.mock而让构建失败时按本文操作即可。不要把它当作一份需要逐步完成的工作积压。适用范围三个共享基础设施客户端本文的迁移对象是三个共享 specifier——它们被大量测试文件直接 mock且每个都在src/__tests__/mocks/下拥有一个规范 Mock 模块specifier规范 Mock 模块客户端根节点语义~/server/db/clientdb.mock.tsdbRead/dbWritePrisma 读写客户端~/server/redis/clientredis.mock.tsredis/sysRedisRedis 客户端及其系统键空间~/server/logging/clientlogging.mock.tslogToAxiomAxiom 日志通道这三个模块在 guarded-specifiers.ts 中被定义为CANONICAL_SPECIFIERS即已有规范 Mock、禁止再直接 mock的一组。守卫测试 no-direct-shared-module-mock.test.ts 通过扫描src/**/*.test.{ts,tsx}中是否存在vi.mock(~/server/db/client, …)之类的调用同时匹配~/…与相对路径两种写法见 mockPattern来判定违规并携带一个只减不增的 allowlist。第 1 步运行 codemod迁移入口是 codemod-shared-mocks.mjs一个基于 TypeScript ASTtypescript包的静态重写工具。三种调用形态# 1. 全仓 dry-run只扫描、只报告不写任何文件repo-wide 安全 node scripts/test-perf/codemod-shared-mocks.mjs --dry --report .test-perf/codemod-report.json # 2. 显式列出要转换的文件 node scripts/test-perf/codemod-shared-mocks.mjs --write file... # 3. 通过文件列表传入Windows 下规避 8191 字符的命令行长度上限 node scripts/test-perf/codemod-shared-mocks.mjs --write --list file-of-paths铁律永远通过--list传入属于你自己的文件清单再--write。裸--write会转换仓库里每一个可转换的文件——这正是某次一个 Agent 静默重写了属于另一个人的 53 个文件的原因。--dry在仓库范围内是安全的因为它只读不写。codemod 的安全边界与拒绝哲学从 convert() 的实现可以看出codemod 只转换它能证明等价的形状并且要么针对某个 specifier 全量转换要么原样保留——绝无部分转换因为残留的vi.mock会重新毒化整个 worker。每次拒绝都会附带原因最终以计数形式汇总如N candidate files | M convertible | K with refusals并按原因分组打印频次若开启了--report机器可读的 JSON 会写入指定路径main()。目前它能处理的形状包括vi.mock(…, () ({ dbRead: { model: { findMany: localSpy } } }))—— 直接绑定本地 spyimportOriginal展开简洁箭头体与块体两种写法都支持const { a, b } vi.hoisted(() ({ a: vi.fn(), b: vi.fn() }))——只移除被转换的条目保留 hoisted 对象中供其他 mock 使用的剩余部分整个客户端以 spy 对象字面量构建如mockDbRead: { $queryRaw: vi.fn(), collection: { findFirstOrThrow: vi.fn() } }折叠为const mockDbRead dbMock.dbRead—— 绑定根节点后每个叶子都会在字面量命名的路径上自动激活vivifyvi.hoisted使用块体、返回属性名指向本地变量时会解析该标识符到其字面量并在该块只读取它恰好一次的前提下连同本地声明一并删除——因此被两个客户端共享的make()辅助函数会原样保留手写的REDIS_KEYS/REDIS_SYS_KEYS与真实常量表做深比较详见下文常量漂移内联 spy 只是复述规范默认值如findUnique: vi.fn(async () null)—— 删除是无副作用的纯透传包装器findMany: (...args) localFn(...args)—— 它们存在的唯一目的是让工厂能触达 hoisted 本地变量工厂消失后便毫无意义。任何携带真实行为的叶子永远被拒绝而非丢弃。这就是 codemod 在所有地方坚守的底线转换它可证明等价的部分其余的全部报告出来见 collect() 中refusals.push的各个分支。第 2 步按频次处理拒绝项以下是按出现频率排序的拒绝原因及手工处理方式。factory declares an export the canonical mock does not own: REDIS_KEYS工厂在客户端旁边手写了真实常量的一部分vi.mock(~/server/redis/client, () ({ redis: { packed: { get: vi.fn() } }, REDIS_KEYS: { CACHES: { PUBLIC_MODEL_RESPONSE: packed:caches:public-model-response } }, }));规范注册通过展开原始包来提供这些常量见 setup.ts因此删除手写副本后测试拿到的是真实的REDIS_KEYS。codemod 替你完成比较它静态解析 packages/civitai-redis/src/client.ts 中的REDIS_KEYS_UNPREFIXED/REDIS_SYS_KEYS见 realConstants()——注意REDIS_KEYS是applyCacheKeyPrefix(REDIS_KEYS_UNPREFIXED)的结果而测试环境下未设置CACHE_KEY_NAMESPACE该函数等价于恒等变换当每个叶子都匹配时删除字面量不匹配时拒绝并在CONSTANTS THAT DRIFTED FROM THE REAL VALUE一节打印分歧。实测42 个文件存在分歧横跨 73 个叶子。有些是占位符rl、kill有些看起来像真的但确实错了——CACHE_LOCKS写成caches:lock而真实值是cache-lockTRPC.LIMIT.BASE写成trpc:rate-limit而真实值是packed:trpc:limit还有一个system:前缀的 sys key 漏写了前缀。它们都不是线上 bug因为测试用同一份错误字符串在自身两侧做断言——这正是为什么仓库里没有任何东西会让它们浮出水面。预期真实常量换入后会出现红测且不要用恢复手写副本来修复。一个变红的测试是在把自己的期望字符串硬编码进 fixture而不是在断言行为。这个红就是发现。⚠️同时预期它们中的大多数不会变红——那才是更糟的情况。在某一个切片中替换了九个虚构常量没有一个产生失败——没有受影响的测试在自身工厂之外引用过它们所以比较的两端都是 fixture 的发明。一个无人断言的漂移常量在两个方向上都不可见它不会失败也无法被审查。其中九个里有六个是new-order键其 fixture 名称是真实键的看似合理的变体如new-order:sanity-check-failures对真实值new-order:sanity-failures——这正是它们能通过阅读审查存活的原因。唯一能让它们浮出水面的手段就是换入真实值——所以即使套件保持绿色也要记录 codemod 报告的漂移项。local mockDb aliases dbMock.dbRead and dbMock.dbWrite一个 spy 同时服务两个客户端导致dbWrite调用能满足dbRead的断言。规范 Mock 中dbRead与dbWrite是刻意不同的两个节点db.mock.ts 明确注释了这一点shared-mocks.test.ts 中也有expect(dbMock.dbRead).not.toBe(dbMock.dbWrite)的断言因此需要拆分并命名代码实际触达的那个客户端。预期其中一部分会变红——那是碰撞浮出水面不是回归。不要把它们再别名回去。hoisted entry is not a bare vi.fn()/no module-scope declaration found通常是带make()辅助函数的手工构建客户端工厂const { mockRead, mockWrite } vi.hoisted(() { const make () ({ appListing: { findUnique: vi.fn(async () null) }, … }); return { mockRead: make(), mockWrite: make() }; }); vi.mock(~/server/db/client, () ({ dbRead: mockRead, dbWrite: mockWrite }));手工处理删除vi.hoisted与vi.mock然后替换为import { dbMock } from ~/__tests__/mocks/db.mock; const mockRead dbMock.dbRead; const mockWrite dbMock.dbWrite;文件其余部分无需任何编辑。阅读旧的make()只保留不是规范默认值的行为——create: async (args) args.data、updateMany: async () ({ count: 1 })之类——作为文件beforeEach中显式的mockImplementation调用。findUnique → null、findMany → []、count → 0已经是默认值复述它们只是噪音。inline behaviour at …规范默认值覆盖不到的行为。保留它在beforeEach中以mockResolvedValue/mockImplementation形式挂到规范节点上即可。注意 codemod 的提升lift机制只允许出现在方法位置——在客户端根节点redis、dbRead上包装整个客户端对象作为 spy 实现会静默丢失其上所有方法scanIterator不再产出、del消失一个断言什么都没被删除的测试会因此变绿collect() 中记录了这起由 archer 的五文件探针发现的事故文件零拒绝地完成转换却丢了八个断言。第 2b 步~/env/server拥有其他模块没有的第二个桶逐文件覆盖无法影响在 MODULE 作用域读取的值。在isolate: false下被测模块在每个 worker 中只求值一次因此只有恰好触发那次求值的文件能看到自己的覆盖。其后的每个文件读到的都是当时已就位的值。这不是规范 Mock 的缺陷——逐文件的vi.mock(~/env/server)工厂有完全相同的问题因为它同样每个 worker 只运行一次。不要试图通过退回逐文件 mock 来修复它。因此 env 分为两个桶值的读取时机放哪里模块作用域const host new URL(env.S3_UPLOAD_ENDPOINT)、pLimit(env.X)env.mock.ts 中的TEST_ENV_DEFAULTS—— worker 级在任何 import 之前设置调用时测试调用的函数内部测试文件中的setEnv({ … })—— 逐文件文件之间重置对应地env.mock.ts 实现了两层读取setEnvDefaults()写 worker 级 defaults 表setEnv()写逐文件 overrides 表read()优先读 overrides、其次读 defaultsread()。resetEnv()只清 overrides不清 defaults——因为模块在 import 时读过一次的值无法被重新回答index.ts 对此有明确注释。另外defaults 的基底不是手抄的而是通过解析serverSchema中每个字段的.default()动态推导schemaDefaults()因此 schema 中新增.default()会自动出现杜绝了 OC-317 那种手抄清单静默漂移的问题。工作示例三个clickhouse/__tests__/tracker.*.test.ts文件各自携带一份提供CLICKHOUSE_TRACKER_URL的vi.mock(~/env/server)而 clickhouse/client.ts 在模块作用域读取它。把那个字符串移入 defaults 并删除全部三个逐文件 mock清掉了 11 个失败。⚠️陷阱测试经常用Object.defineProperty(env, FLAG, …)翻转单个变量。如果 proxy 上报的 descriptor 与随后defineProperty写入的内容不一致第二次调用会抛TypeError: Cannot redefine property——表现为一个文件里十几个互不相关的失败症状完全指向不到病因。env.mock.ts 的 proxy 实现了defineProperty/set/deleteProperty/getOwnPropertyDescriptor的一致语义descriptor 始终携带value且保持configurable: truegetter 以__envAccessor标记并每次访问时重读若你要扩展它请保持这种一致性。有些 env 测试无法迁移这是 flag 的属性而非 Mock 的缺口一个测试在模块作用域双向设置的布尔量——IS_BUILD、IS_DATAPACKET——在isolate: false下无法用任何机制实现逐文件变化。读取它的模块每个 worker 只求值一次只有第一个文件的值永远可见。这不是规范 env Mock 的缺陷逐文件vi.mock工厂有完全相同的问题任何未来的方案也都会有。这些测试的存在正是为了验证 build-vs-runtime 与 datapacket-vs-not 行为所以选项是停止通过env测试它——改为 mock 消费方模块导出的结果——或者让这些文件保持隔离。把它们当作永久排除项而非剩余工作也不要让一个包含它们的未迁移计数读起来像还有进展要做。第 3 步修复两种失效的断言形态缺席断言Absence assertions。测试可能通过 fixture缺少某个方法来证明一条代码路径expect((db.dbRead.appBlockPublishRequest as Recordstring, unknown).findFirst).toBeUndefined();规范 Mock 会激活每一个方法缺席不再可观测。改写为行为化断言expect(dbMock.dbRead.appBlockPublishRequest.findFirst).not.toHaveBeenCalled();这更强而不是更弱如果读取被路由到副本包括另一个文件已填充该方法之后它依然会失败。工作示例见 purge-review-snapshots.test.ts其中mockFindFirst、mockFindMany均取自dbMock并在beforeEach中显式mockReset 配置返回值。节点上的toBeUndefined()在设计上就是错的尽管它看起来像显而易见的检查。未设置的属性依然会激活成节点所以断言会以expected [Function Mock] to be undefined失败。当你想表达上一个文件的值没有存活下来请直说expect(node.isReady).not.toBe(false)。赋值的数据属性会被跟踪并在每个文件间清除——sysRedis.isReady false就是真实案例——但已清除意味着赋值没了而不是属性读起来像缺席。hybrid.ts 解释了为何必须如此没有set陷阱的话赋值会落到底层vi.fn的自有属性上而mockReset()只清 mock 状态不清任意属性在isolate: false下该值会存活整个 worker 生命周期并泄漏进后续每个文件。对键做断言命名常量且线值只钉一次。expect(incrKey).toContain(REDIS_SYS_KEYS.BLOCKS.REVIEW_RUN_FOR_REAL_BUZZ_CAP); expect(REDIS_SYS_KEYS.BLOCKS.REVIEW_RUN_FOR_REAL_BUZZ_CAP) .toBe(system:blocks:review-run-for-real-buzz-cap);它们守护的是不同的失败谁也不包含谁。在行为断言中命名常量能阻止测试重新发明键——正是那个一天之内造出十四个虚构值的类别。golden value 让重命名变得响亮键的线值寻址的是部署代码写下的线上 Redis 条目改变它会遗弃旧名之下的所有内容这应该是刻意的决策而不是测试静默跟随的东西。beforeEach中冗余的mockReset()。无害但全局 setup 已经在文件被 import 之前重置了每个节点。既然已经在文件里就删掉它注意mockReset()也会清掉规范DEFAULT所以一个依赖 reset 后findMany → []的测试必须自己设置该值。第 4 步验证——用计数永远不用颜色在--no-isolate下模块作用域抛错的文件会收集到ZERO个测试。失败计数不会上升运行可能读起来是绿的而测试其实根本不在。所以node scripts/test-perf/run-pilot.mjs --label mine-iso-4 --workers 4 --list list node scripts/test-perf/run-pilot.mjs --label mine-noiso-4 --workers 4 --no-isolate --list list node scripts/test-perf/compare-runs.mjs mine-iso-4 mine-noiso-4run-pilot.mjs 在指定 worker 数与隔离模式下运行 pilot 文件集并把逐文件计时/失败 JSON 写入.test-perf/runs/。它通过node node_modules/vitest/vitest.mjs直接派生 vitest不经.binshim 与 shell——几百个文件路径会撑爆 cmd.exe 的 8191 字符命令行且失败会以普通非零退出码呈现读起来像套件失败。而 compare-runs.mjs 先 diff 逐文件收集计数、把计数回归打印在失败之前且任何文件收集到的测试变少就非零退出哪怕零失败。还有两条规则都是在这个环境里用代价换来的在两个 worker 数下验证。失败集合是顺序相关的——同一份代码、同一个 flag8 个 worker 下 1574 个失败31 个 worker 下 1161 个。单宽度下的绿跑不是证据。绝不要用| tail或| grep读套件结果。你拿到的是管道的退出码。重定向到文件再读文件。在宣布一组文件迁移完成之前确认其中没有任何文件仍在 mock 规范 specifiernode scripts/test-perf/residual-mocks.mjs listresidual-mocks.mjs 扫描清单中仍直接 mock 三个规范 specifier 的文件并写入.test-perf/residual-mocks.json。单个掉队文件会把自己的 mock 形状冻结进其 worker 中的每个消费模块所以部分迁移的集合测量的是掉队者而非系统。实测同一份 174 个文件在 83 个已迁移时从 404 个失败降到 290而完全干净的 83 个单独跑只有 11 个失败。第 5 步更新 allowlistnode scripts/test-perf/gen-mock-allowlist.mjsallowlist 是派生状态。任何触碰测试文件的 merge 之后都必须重新生成。解决冲突时取双方通常是对的但如果一方删除了一个直接 mock该文件现在已迁移其 allowlist 条目就过期了——这会让守卫的第二个方向失败。这事已经发生过一次在某个集成分支上而那次守卫是 16,806 个测试的运行中唯一的失败。gen-mock-allowlist.mjs 写入 src/tests/mocks/direct-mock-allowlist.json并且拒绝增长——一次会新增条目的运行会以非零退出并点名那些条目因为列表增长意味着有人新增了直接 mock而守卫存在的意义就是阻止这个。no-direct-shared-module-mock测试no-direct-shared-module-mock.test.ts还会对不再 mock 任何东西的条目报错所以列表无法被充数它的长度就是迁移的剩余工作量。注意该守卫还带有正向控制expect(scanned).toBeGreaterThan(800)防止扫描树静默缩小后所有断言变成空洞真值并且扫描在测试内部执行而非模块作用域——历史上它曾因模块作用域抛错而收集零测试、在每次全量运行中缺席、以命名文件方式调用时却总是通过。翻转门槛——在翻转前一刻跑一次全量套件的收集计数 diff这是阻塞性前提不是建议。isolate: false在确切要发布的树上通过以下检查之前不得上线# 同一时间窗口背靠背 whole unit suite, migration branch --label flip-candidate whole unit suite, main control --label flip-control node scripts/test-perf/compare-runs.mjs flip-control flip-candidate通过条件全部满足零个文件比 control 收集到的测试更少分支新增的每个文件至少收集到一个测试总数完全相等——不是没有新失败不是没有回归而是计数相等跑全量套件而不是按切片跑在翻转前立刻跑而不是迁移期间跑一次。新增文件条件的存在是因为第一个条件有个洞而这个洞是通过跑门槛而非推理发现的。分支新增的文件在 control 侧没有可对比的对手所以它可以收集zero个测试并且 diff 完美干净。这正是本项目自己的迁移守卫曾经以惰性形态上线的方式它在每次全量套件运行中收集阶段就抛错、贡献零测试而以命名文件方式被调用时却总是通过——于是它被检查了一整天。最后两个条件才是重点且各有原因。全量套件因为按切片的验证既正确又不充分。每个切片负责人对自己文件做逐文件收集计数 diff 是正确的实践但它仍然留着一个缺口一个无人认领的文件可能收集零个测试而没有任何负责人的 control 覆盖到它。这不是假设。规范 Mock 注册时带过一个importOriginal展开让一个文件在模块作用域死掉、贡献为零120 文件 pilot 用逐文件收集计数验证过却不可能抓到它因为受影响的文件在集合之外。它是由某人在转换任何东西之前对另一个切片跑 control 时发现的。失败不在任何人的工作里——它在所有人工作的缝隙里。翻转前立刻跑因为这个性质只对特定的一棵树成立。迁移期间的一次干净 diff说明不了三百次转换之后的树。这是对要发布的东西的门槛不是里程碑。它很便宜。2026-08-15 的全量集成运行是 1069 个文件 / 16806 个测试、0 个零收集文件约四分钟。这项检查已证明在完整规模下有效它是一次运行不是一个项目。为什么一行摘要满足不了它在--no-isolate下模块作用域抛错的文件收集zero个测试。失败计数不上升。运行读起来是绿的。迁移期间发现的每个实例都以那种方式呈现没有一个看起来像错误mock 工厂在预打包pre-bundling下缺了default键收集到 7 个测试而非 106 个报告为 1 个失败importOriginal-on-a-shim 回归870 通过、0 失败一个文件贡献为零缺了event-engine-common子模块预先存在CLAUDE.md 有记录整个套件收集 0 却仍读作通过。compare-runs.mjs就是为此而生的它把收集计数回归打印在失败之前并且在零失败时也因计数回归而非零退出。规范的三个并不构成静默类别的边界对任何多导出模块的全量vi.mock都会引发它。有一次一个测试 mock 了~/server/utils/server-domain——14 个导出——却只给了单键工厂在--no-isolate下这冻结了该 worker 中的模块之后某个消费方触达其余 13 个导出之一的文件在模块作用域死掉、收集零测试。它在每个 worker 数下干掉的是不同的文件这正是它表现为偶发而非单个坏文件的原因。所以当一个已迁移集合仍有零收集时在--no-isolate日志里 grepNo export export is defined on the——这条消息点名了被毒化的模块而且通常不是这三个之一。对已迁移集合做一次多导出模块全量 mock扫描是廉价的预防手段。门槛抓不到什么收集计数与residual-mocks.mjs检测的是缺席——一个丢失了测试的文件、一个仍被直接 mock 的 specifier。两者都看不见一个仍在运行、仍在通过、却已不再断言任何真实内容的测试。这也不是假设。有一个 codemod 形状丢掉了工厂的withSysReadDeadline让session-verifier.test.ts的两条 fail-open 分支在断言虚无的情况下通过——因为测试替换成 spy 的那个导出正是它注入超时的方式。另一个把整个客户端对象包成了叶子 spy每个方法消失、调用返回空而非抛错一个断言什么都没被删除的测试会变绿。两个文件都以零拒绝完成转换、保住了每个测试、在 residuals 上读起来干净。抓住它们的是在断言层面、小集合上、由没写工具的人跑的一对 control。在门槛之外持续这样做那是门槛做不到的一半。以及一般形式它比这次迁移活得更久这一天最有价值的两个发现都来自别人对另一个人的工作跑 control——而非作者自己的验证那很仔细并且通过了。第三次修正方向相反从作者到审查者。一天三次两个方向都有。作者验证他们改过的东西。没人验证的是他们周围被改动的东西。在多人分担的工作上至少预算一次不属于任何切片的全量套件独立 control。悬而未决的问题browser-mode 测试守卫检测.test.tsx——否则一个.browser.test.tsx添加直接规范 mock 会成为它在结构上无法观测的类别这与恰好为空的类别不同。而codemod 刻意只处理.ts转换 browser-mode 文件会把它放进规范 Mock 从未被验证过的环境一次 glob 改动会让那看起来像受支持的路径。注意缺失的形态比browser mode 能否工作更窄今天有zero个.tsx文件 mock 规范 specifier所以证明它需要有人写一个而不是迁移一个。这是一份工作不是一次验证。本配方不覆盖的内容~/env/server114 处、~/server/services/buzz.service98 处、~/server/services/image.service88 处。基础设施客户端适合一个自动激活的单一原语服务模块有手写的表面所以它的规范 Mock 是手写的 stub那是另一份工作。~/env/server之后已被单独完成——它需要的是值表而非调用表面见上文双桶规则。guarded-specifiers.ts 的PENDING_SPECIFIERS记录了下一梯队的候选项且明确标注它是下限而非清单specifier 会从另一个方向不断到来——即对列表上所有项都 residual-clean 的文件集在--no-isolate下跑出的失败中。它也不覆盖生产代码中的模块作用域状态。一个断言 import 时副作用的测试——如 eventloop-longtask.ts 把指标注册进真实的 prom registry——在--no-isolate下会失败因为模块每个 worker 只 import 一次第一个触达它的文件拿走注册。那属于no-module-scope-cache的范畴不在这里。与机制文档的衔接本文是配方怎么做而机制与设计在 shared-module-mocks.md为何vi.mock是逐文件的而模块实例是逐 worker 的、毒化如何经由共享的非测试模块传播、混合 callable proxy 原语hybrid.ts如何让dbRead.image.findMany、dbRead.$queryRaw、redis.packed.get无需枚举即可存在、规范默认值表findMany → []、findUnique → null、count → 0、$queryRaw → []、$executeRaw → 0、$transaction运行回调为何刻意保守、以及每个 specifier 全有或全无的迁移约束。注册入口在 src/tests/setup.ts规范 Mock 通过展开底层包而非对 shim 使用importOriginal注册——~/server/db/client与~/server/redis/client是 shim它们既整包 re-export 又在模块作用域构造真实客户端importOriginal会把真实的 Prisma/Redis 构造强塞进每一个测试文件process-vault-items.test.ts正是因此死在模块作用域、收集零测试而失败计数纹丝不动。底层的每文件重置钩子resetSharedMocks()在 mocks/index.ts它在两种隔离模式下都正确isolate: true时注册表每个文件重建、重置是 no-opisolate: false时它才是唯一清除实现与调用计数的东西。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考