Repository Wiki
LyraVoid/Mizuki

测试体系

Mizuki 采用零外部测试依赖的方案:所有测试基于 Node.js 内置的 node:test 运行器与 node:assert/strict 断言库编写,统一存放在 tests/ 目录下,通过 package.json 中的 npm scripts 驱动执行,并辅以构建期检查脚本形成多层守护。

目的与范围

本页完整覆盖 Mizuki 测试机制的技术实现,包括:

  • 执行入口与命令链(npm test、npm run check、npm run build 中的验证环节)
  • tests/ 目录下的测试套件全景与命名约定
  • 三种核心测试编写模式:声明式单元测试、源码文本回归守护测试、独立脚本式跨端端到端测试
  • 构建期守护脚本(字体加载检查、全局样式加载检查)
  • 失败模式、边界情况、运维与扩展方式

以下相关主题有意留给兄弟页面,本页只做交叉引用:

  • 加密系统本身的算法与组件行为 → 见「加密体系」相关页面
  • scripts/read-site-config.mjs 配置读取脚本的完整实现 → 见「配置体系」页面
  • Astro 构建管线与部署流程 → 见「构建与部署」页面

概述

Mizuki 是一个 Astro 静态站点框架,运行时产物是纯静态文件,因此其质量风险与传统的服务端应用不同:多数风险不在请求处理路径上,而在**「源码结构与约定被意外破坏」**——例如 RSS 与 Atom 路由忘记共用同一个内容渲染器、Markdown 增强功能被重构移除、字体加载方式发生回退。针对这一特点,测试体系形成了三个鲜明的设计取向:

  1. 零依赖:不引入 Jest / Vitest / Mocha 等任何第三方测试框架,直接使用 node:test 与 node:assert/strict。tests/crypto.test.mjs 的文件头注释明确声明了这一点:
js
1/** 2 * 加密系统端到端测试 3 * 运行方式: node tests/crypto.test.mjs 4 * 无需任何测试框架依赖 5 */

crypto.test.mjs

零依赖带来的直接收益是:CI 环境无需安装额外包、测试可在任意具备 Node 的环境中运行、不存在测试框架版本与项目工具链(Biome、Astro)的兼容性问题。

  1. 源码即被测对象:相当一部分测试不执行被测代码,而是把源码文件当作文本读入,用正则断言其包含(或明确不包含)特定模式。这是一种「回归守护」性质的测试——锁定架构约定,防止实现回潮或漂移。

  2. 跨端互操作性用复刻法验证:对加密这类「服务端加密、浏览器端解密」的跨端契约,测试在同一个进程内分别复刻服务端实现与客户端内联脚本,验证两者字节级兼容。

架构

Loading diagram...

架构说明:

  • 执行入口:npm test 是测试的主入口,它用 && 串联了两个阶段——先跑 node --test 声明式套件,再跑独立脚本式加密测试;任何一个阶段失败都会让整体退出码非零。类型检查(astro check、tsc --noEmit)与构建期检查是独立的补充层。
  • 测试运行层:--experimental-strip-types 标志让 Node 可以直接执行 TypeScript 测试文件(剥除类型注解),--test 启用内置测试运行器。这样 .test.ts 与 .test.mjs 可以用同一条命令驱动。
  • 被测对象分三类:源码文本(只读断言)、可直接 import 的纯函数模块、以及专门构造的内容管线夹具文章。
  • 构建期守护:build 脚本在 astro build 与 pagefind 索引生成之后分别执行样式加载与字体加载检查,把「验证」前置到产物生成阶段,失败即中止构建链。

执行入口与命令链

package.json 中与测试/验证相关的脚本如下:

脚本命令作用
testnode --experimental-strip-types --test tests/markdown-enhancements.test.mjs tests/layout-regressions.test.mjs tests/image-loading.test.mjs tests/music-player-loading.test.mjs && node tests/crypto.test.mjs默认测试集:4 个 node:test 套件 + 1 个独立加密测试
checkastro checkAstro 组件/模板的类型诊断
type-checktsc --noEmitTypeScript 全量类型检查
buildnode scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs构建链内嵌两道验证门
check-fontsnode scripts/check-font-loading.mjs单独运行字体加载检查

值得注意的设计点:默认 npm test 命令显式列出要跑的测试文件,而不是依赖 node --test 的目录自动发现。这样默认测试集是精确可控的;其余套件(如 site-config-reader.test.ts、content-pipeline.test.mjs)按需通过同样的运行器手动执行:

bash
node --experimental-strip-types --test tests/site-config-reader.test.ts node --test tests/content-pipeline.test.mjs

测试套件全景

tests/ 目录下的全部套件及其所属模式:

测试文件模式被测对象核心断言
markdown-enhancements.test.mjsnode:test + 源码文本守护Markdown 渲染配置Markdown 增强能力(Wiki Links 等)未被移除/回退
layout-regressions.test.mjsnode:test + 源码文本守护布局组件布局回归防线,锁定结构约定
image-loading.test.mjsnode:test + 源码文本守护图片加载实现图片加载方式符合约定
music-player-loading.test.mjsnode:test + 源码文本守护音乐播放器组件播放器加载路径符合约定
crypto.test.mjs独立脚本 + 复刻互验加密体系(服务端加密 / 客户端解密)AES-256-GCM 契约两端字节级兼容
content-pipeline.test.mjsnode:test + 源码文本守护content-pipeline-fixture.mdx、rss.xml.ts、atom.xml.ts、feed-data.ts内容管线夹具覆盖面足够广;RSS/Atom 共用同一个渲染器
site-config-reader.test.tsnode:test + 纯函数单测scripts/read-site-config.mjs 的 extractBlock / matchInBlock配置块解析、覆盖优先级、块边界隔离
config-overrides.test.tsnode:test配置覆盖机制覆盖文件与默认配置的合并语义
content-links.test.tsnode:test内容链接处理链接生成约定
feed-content.test.tsnode:testFeed 内容工具Feed 内容抽取逻辑
post-card-content.test.tsnode:test文章卡片组件逻辑卡片内容组装
post-cover-source.test.tsnode:test封面图来源选择封面来源优先级
post-date-utils.test.tsnode:test日期工具函数日期格式化/解析
mermaid-interactions.test.mjsnode:test + 源码文本守护Mermaid 交互脚本Mermaid 图表交互行为未被破坏
font-loading.test.mjsnode:test + 源码文本守护字体加载实现字体加载方式符合约定(与构建期 check-font-loading.mjs 呼应)

命名约定:所有测试文件以 .test.mjs(无类型)或 .test.ts(有类型,需 --experimental-strip-types)结尾,与 scripts/ 下的运维脚本区分开。

核心模式一:声明式单元测试(node:test)

绝大多数套件采用 Node 内置测试运行器的 describe / it 声明式写法,断言统一使用 node:assert/strict。以配置解析函数的单元测试为例:

ts
1import assert from "node:assert/strict"; 2import { describe, it } from "node:test"; 3 4import { extractBlock, matchInBlock } from "../scripts/read-site-config.mjs"; 5 6// 上游默认配置的缩影:字段齐全,块顺序固定 7const DEFAULTS = ` 8export const siteConfig: SiteConfig = { 9 navbarTitle: { mode: "text-icon", text: "MizukiUI" }, 10 font: { mode: "custom" }, 11 bangumi: { userId: "your-bangumi-id", fetchOnDev: false }, 12 bilibili: { vmid: "", coverMirror: "", useWebp: true }, 13 anime: { mode: "local" }, 14}; 15`;

site-config-reader.test.ts

这段代码体现了三个关键实践:

  1. 直接 import 被测模块:被测对象是 scripts/read-site-config.mjs 导出的纯函数 extractBlock / matchInBlock,测试通过模块导入执行真实实现,而非文本匹配。
  2. 内联夹具(inline fixture):DEFAULTS 常量是真实上游默认配置的"缩影",字段齐全、块顺序固定。将夹具内联进测试文件,既避免测试依赖真实配置文件的内容变化,又能精确控制输入形态。
  3. 宽松模式:--experimental-strip-types 剥除类型后,.ts 测试文件可在纯 Node 进程中运行。

覆盖的典型行为(节选自同一文件):

ts
1 it("取值不会越过块边界串到相邻配置", () => { 2 // anime 块是空的,后面 font.mode 不能被当成番剧模式 3 const override = `export default { 4 anime: {}, 5 font: { mode: "system" }, 6 };`; 7 8 assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "local"); 9 }); 10 11 it("覆盖文件里不存在的块直接跳过", () => { 12 const override = `export default { title: "我的站点" }`; 13 14 assert.equal(matchInBlock([override, DEFAULTS], "anime", MODE), "local"); 15 });

site-config-reader.test.ts

这里专门测了一个边界情况:正则在某个配置块内找不到目标时,绝不能越界匹配到相邻块(anime 为空块时不能把 font.mode 误读为番剧模式)。这类「负向断言」是文本解析类逻辑最容易出错的地方,也是该套件的测试重点。

设计意图:read-site-config.mjs 会被 predev / prebuild 等脚本在每次构建前调用,解析结果直接影响站点行为;把它当作纯函数单测对象,可以在毫秒级验证其解析语义,无需启动 Astro。

核心模式二:源码文本回归守护测试

这是 Mizuki 测试体系中最具特色的一类。测试不执行被测代码,而是用 node:fs/promises.readFile 把源码读成字符串,再用正则做包含/排除断言。content-pipeline.test.mjs 是典型:

js
1import assert from "node:assert/strict"; 2import { readFile } from "node:fs/promises"; 3import { describe, it } from "node:test"; 4 5const fixtureSource = await readFile( 6 new URL("../src/content/posts/content-pipeline-fixture.mdx", import.meta.url), 7 "utf8", 8); 9const rssSource = await readFile( 10 new URL("../src/pages/rss.xml.ts", import.meta.url), 11 "utf8", 12); 13const atomSource = await readFile( 14 new URL("../src/pages/atom.xml.ts", import.meta.url), 15 "utf8", 16); 17const feedDataSource = await readFile( 18 new URL("../src/utils/feed-data.ts", import.meta.url), 19 "utf8", 20);

content-pipeline.test.mjs

注意两个细节:

  • 顶层 await:.mjs 文件支持顶层 await,模块加载时即读取源码,describe 内部直接使用。
  • 基于 import.meta.url 的相对路径:new URL("../src/...", import.meta.url) 使测试可以从任意工作目录运行,不依赖 CWD。

随后是两个守护性断言:

js
1 it("routes RSS and Atom through one shared content renderer", () => { 2 for (const source of [rssSource, atomSource]) { 3 assert.match(source, /getFeedContentItems/); 4 assert.doesNotMatch(source, /MarkdownIt|markdownParser\.render/); 5 } 6 assert.match(feedDataSource, /renderPostContent/); 7 });

content-pipeline.test.mjs

这条测试锁定的架构约定是:RSS 与 Atom 两个 Feed 路由必须共用同一个内容渲染器(getFeedContentItems),且不允许直接出现 MarkdownIt 或 markdownParser.render——防止有人为了"修个 bug"在某一个路由里私自引入旧的独立渲染路径,导致两个 Feed 内容格式漂移。assert.doesNotMatch 负向断言在这里承担了「禁止回潮」的角色。

另一条断言则验证夹具文章覆盖了内容管线的全部关键特性:

js
1 it("covers MDX, callouts, Wiki Links, code groups, math, and images", () => { 2 assert.match(fixtureSource, /^import ContentPipelineFixture/m); 3 assert.match( 4 fixtureSource, 5 /<ContentPipelineFixture label=\{componentMessage\}/, 6 ); 7 assert.match(fixtureSource, /:::note/); 8 assert.match(fixtureSource, /\[\[guide\]\]/); 9 assert.match(fixtureSource, /::: code-group/); 10 assert.match(fixtureSource, /\\ce\{/); 11 assert.match(fixtureSource, /topics\.join\(/); 12 assert.match(fixtureSource, /!\[A square demonstration image/); 13 });

content-pipeline.test.mjs

content-pipeline-fixture.mdx 是一篇专门为测试而存在的文章,其价值不在于内容,而在于它把 MDX 组件、callout 语法(:::note)、Wiki Links([[guide]])、代码分组(::: code-group)、化学公式(\ce{)、图片等所有内容管线特性集中在一处。本断言确保夹具不被删减——一旦有人清理掉夹具中的某个特性,测试立刻失败,提醒"该特性在夹具中失去了覆盖"。这类测试与可视化冒烟检查(人工浏览该夹具页面)配合使用。

适用边界:文本守护测试验证的是「结构约定存在」,不验证运行时行为,因此它适合锁定架构决策;行为正确性仍需依靠纯函数单测与端到端复刻测试补足。

核心模式三:独立脚本式跨端端到端测试

crypto.test.mjs 验证的是加密体系的跨端契约:服务端在构建期用 Node crypto 加密内容,浏览器端用 WebCrypto crypto.subtle 解密。两端运行时不同,契约必须字节级一致。该测试采用「复刻互验」策略,且不使用 node:test,而是一个自包含脚本:

js
1import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto"; 2 3// 从源文件复制的常量(必须与 crypto-utils.ts 保持同步) 4const CRYPTO_CONSTANTS = { 5 PBKDF2_ITERATIONS: 100000, 6 SALT_LENGTH: 16, 7 IV_LENGTH: 12, 8 AUTH_TAG_LENGTH: 16, 9 KEY_LENGTH: 32, 10 VERIFY_PREFIX: "MIZUKI-VERIFY:", 11};

crypto.test.mjs

服务端加密的复刻(对应 crypto-utils.ts):

js
1function encryptContent(html, password, slug) { 2 const { PBKDF2_ITERATIONS, SALT_LENGTH, IV_LENGTH, KEY_LENGTH, VERIFY_PREFIX } = CRYPTO_CONSTANTS; 3 const plaintext = VERIFY_PREFIX + html; 4 const salt = deriveBytes(password, `salt:${slug}`, SALT_LENGTH); 5 const iv = deriveBytes(password, `iv:${slug}`, IV_LENGTH); 6 const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); 7 const cipher = createCipheriv("aes-256-gcm", key, iv); 8 const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); 9 const authTag = cipher.getAuthTag(); 10 return Buffer.concat([salt, iv, authTag, encrypted]).toString("base64"); 11}

crypto.test.mjs

客户端解密的复刻(对应 PasswordProtection.astro 的内联脚本):

js
1async function clientDecrypt(encData, password) { 2 const { PBKDF2_ITERATIONS, SALT_LENGTH, IV_LENGTH, AUTH_TAG_LENGTH, VERIFY_PREFIX } = CRYPTO_CONSTANTS; 3 const raw = Buffer.from(encData, "base64"); 4 const salt = raw.subarray(0, SALT_LENGTH); 5 const iv = raw.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); 6 const authTag = raw.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); 7 const ciphertext = raw.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); 8 9 const combined = Buffer.concat([ciphertext, authTag]); 10 11 const enc = new TextEncoder(); 12 const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]); 13 const aesKey = await crypto.subtle.deriveKey( 14 { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, 15 keyMaterial, { name: "AES-GCM", length: 256 }, false, ["decrypt"], 16 ); 17 const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, combined); 18 const decoded = new TextDecoder().decode(decrypted); 19 20 if (!decoded.startsWith(VERIFY_PREFIX)) { 21 throw new Error("Verification prefix mismatch"); 22 } 23 return decoded.substring(VERIFY_PREFIX.length); 24}

crypto.test.mjs

测试主体使用自研的 assert 辅助函数与 passed / failed 计数器,在进程退出前汇总通过/失败数量——这就是它不依赖任何框架也能输出测试报告的方式:

js
1const testHtml = "<h1>Hello World</h1><p>这是一篇加密文章的内容</p>"; 2const testPassword = "test-password-123"; 3const testSlug = "encrypted-test-post"; 4 5let passed = 0; 6let failed = 0; 7 8function assert(condition, message) { 9 if (!condition) throw new Error(`Assertion failed: ${message}`);

crypto.test.mjs

设计意图与风险提示:常量是"从源文件复制"的,注释明确要求必须与 crypto-utils.ts 保持同步。这是一种刻意的取舍——通过复刻而非 import 内联脚本(浏览器端脚本嵌在 .astro 文件中,无法被 Node 直接 import),换取了对 WebCrypto 路径的真实执行;代价是常量漂移风险需要靠注释纪律约束。若 crypto-utils.ts 中的参数(如迭代次数)变更而测试常量未同步,测试仍会通过但已不再反映真实契约。

核心流程:一次 npm test 的执行时序

Loading diagram...

流程要点:

  1. 两段串联:&& 保证只有第一阶段(node:test 套件)全部通过后才进入第二阶段(独立加密测试)。这样加密契约测试排在整个测试链的最后执行。
  2. 模块加载即准备:node:test 套件在模块加载阶段(顶层 await)就完成源码读取,describe 注册与断言执行由运行器统一调度。
  3. 退出码聚合:独立脚本通过计数器把多个用例的结果汇总成一个退出码,保持与 && 链的兼容性。

构建期守护脚本

除了 tests/ 目录,build 脚本内嵌了两道验证门,它们检查的是构建产物而非源码文本,与测试体系互补:

json
"build": "node scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs",

package.json

守护脚本执行时机检查对象
check-global-style-loading.mjsastro build 完成后、pagefind 之前全局样式在产物中被正确加载
check-font-loading.mjspagefind 索引生成后(构建链末尾)字体在产物中被正确加载(可用 npm run check-fonts 单独运行)

设计意图:把验证放在「产物已生成、后续步骤尚未执行」的位置,可以在样式/字体回退的第一时间中止构建链,避免带着缺陷产出最终站点。这些脚本与 tests/font-loading.test.mjs(源码层面的字体加载守护)形成"源码 + 产物"双层防线。

使用示例

运行默认测试集

bash
npm test

等价于 package.json 中的两段串联命令(见上文「执行入口与命令链」)。任何一个阶段失败,整体退出码非零,CI 可直接据此判定。

运行单个测试文件

由于 npm test 显式列出文件清单,其余套件按需单独执行:

bash
1# TypeScript 测试需要 --experimental-strip-types 剥除类型 2node --experimental-strip-types --test tests/site-config-reader.test.ts 3 4# 普通 .mjs 测试直接运行 5node --test tests/content-pipeline.test.mjs 6node --test tests/post-date-utils.test.ts

--test 标志启用 Node 内置运行器的测试发现与 TAP 输出;不加 --test 也能执行,但不会得到运行器的汇总报告。

运行独立脚本式测试

bash
node tests/crypto.test.mjs

无任何额外标志,输出由脚本内的 passed / failed 计数器产生。

新增一个源码文本守护测试的最小模板

结合仓库中既有套件的写法,新增守护测试只需:

js
1import assert from "node:assert/strict"; 2import { readFile } from "node:fs/promises"; 3import { describe, it } from "node:test"; 4 5const targetSource = await readFile( 6 new URL("../src/pages/some-endpoint.ts", import.meta.url), 7 "utf8", 8); 9 10describe("some-endpoint 守护", () => { 11 it("保持共用渲染器约定", () => { 12 assert.match(targetSource, /getFeedContentItems/); 13 assert.doesNotMatch(targetSource, /MarkdownIt/); 14 }); 15});

注意:该示例为按仓库既有模式构造的最小模板(结构与 tests/content-pipeline.test.mjs 一致),仓库中并不存在名为 some-endpoint.ts 的文件。

配置项

测试体系本身没有独立的配置文件(无 vitest.config / jest.config 等),全部行为由命令行标志与 npm scripts 决定:

配置项类型默认值说明
测试文件匹配命令行参数tests/*.test.mjs 中显式指定的 4 个文件npm test 显式列出,非自动发现
--experimental-strip-typesNode CLI 标志开启(仅第一阶段)允许直接运行 .test.ts,剥除类型注解
--testNode CLI 标志开启(仅第一阶段)启用内置测试运行器
断言库import 选择node:assert/strict严格模式断言(=== 语义,无隐式转换)
退出码链npm scripts&& 串联任一阶段失败即整体失败
运行时依赖package.json无零第三方测试框架依赖

API 参考

测试体系中被测的关键可导入函数(来自 scripts/read-site-config.mjs,由 tests/site-config-reader.test.ts 覆盖):

matchInBlock(sources: string[], block: string, regex: RegExp): string | null

参数:

  • sources(string[]):按优先级排列的配置源文本数组(覆盖文件在前,默认配置在后)
  • block(string):目标配置块名(如 "anime"、"bilibili")
  • regex(string | RegExp):提取目标字段的捕获组正则

返回值: 第一个在指定块内匹配到捕获组的来源所捕获的值;所有来源都没有该块时返回 null,由调用方兜底。

行为契约(由测试用例验证):

  • 覆盖文件中的块优先于默认配置中的同名块
  • 覆盖块中缺失的字段会继续回退到默认配置(如只覆盖 vmid 时 coverMirror / useWebp 仍取默认值)
  • 匹配严格限制在块边界内,不会越界读到相邻配置块
  • 覆盖文件中不存在的块直接跳过,继续尝试下一个来源

extractBlock(...)

与 matchInBlock 协同的块提取函数,两者共同构成配置解析基础。完整签名与实现细节见 scripts/read-site-config.mjs(属于「配置体系」页面范围,本页不展开)。

其余被测对象(如 feed-data.ts、post-date-utils 等)的函数级 API 由各自模块页面覆盖。

失败模式、边界情况与并发

失败如何表现

  • node:test 套件:断言失败抛出异常,运行器捕获后输出 TAP 格式的失败详情(用例名、断言位置),进程退出码非零。
  • 独立脚本套件:assert(condition, message) 抛出 Assertion failed: <message>;脚本以 passed / failed 计数汇总,失败数大于 0 时退出码非零。
  • && 链:第一阶段失败则第二阶段根本不会执行,避免在基础守护未通过时浪费加密测试时间。

已被测试覆盖的边界情况

边界情况所在套件验证方式
配置块为空时不能越界匹配相邻块site-config-reader.test.tsanime: {} + font.mode 场景下的负向断言
覆盖文件缺少整个配置块site-config-reader.test.ts覆盖文件只含 title 时回退默认值
所有来源都没有目标块site-config-reader.test.ts断言返回 null 由调用方兜底
加密验证前缀不匹配crypto.test.mjsVERIFY_PREFIX 校验抛出 Verification prefix mismatch
Feed 路由引入私有渲染路径(回潮)content-pipeline.test.mjsassert.doesNotMatch 禁止 MarkdownIt / markdownParser.render
内容夹具特性被删减content-pipeline.test.mjs对夹具 8 个特性逐一正则断言

并发与执行顺序

  • 每个测试文件在独立的 Node 进程参数中列出,运行器按声明顺序执行;源码读取发生在模块加载阶段(顶层 await),与用例执行解耦。
  • 测试均为只读操作(读源码文件、内存中运算),不写任何文件、不依赖网络、不依赖外部服务,因此天然无并发副作用,可安全地在 CI 中并行调度多个文件。
  • crypto.test.mjs 的 PBKDF2 迭代(100000 次)是整个测试链中最耗时的 CPU 计算点,属于已知开销。

已知局限

  1. 常量漂移风险:crypto.test.mjs 复制的 CRYPTO_CONSTANTS 依赖人工同步,无机制强制(见上文风险提示)。
  2. 文本守护不验证运行时行为:正则只能证明"约定存在",不能证明 Markdown 渲染结果正确;行为验证依赖夹具页面的人工/可视化冒烟。
  3. 默认 npm test 不覆盖全部套件:site-config-reader.test.ts、content-pipeline.test.mjs 等需要手动执行,存在"忘跑"的风险敞口。

性能与运维考量

  • 测试即依赖扫描:node:test 套件在模块加载阶段即读取被测源码,相当于隐式验证了目标文件的存在性与可读性——源文件被移动/重命名时,测试会在读取阶段就失败并给出明确路径。
  • 构建链内嵌验证的取舍:check-global-style-loading.mjs 与 check-font-loading.mjs 插在构建链中间(而非之后单独跑),好处是失败立即中止、不产出带缺陷产物;代价是每次构建都必须完整执行这些检查,无法跳过。
  • CI 友好:零依赖 + 无网络 + 无文件写入,测试可在任何具备 Node 的环境(CI 容器、本地、CI 缓存层)直接运行,无需安装步骤。
  • --experimental-strip-types 的版本约束:该标志要求 Node 版本支持类型剥除(Node 22.6+ / 启用相应实验特性),是运行 .test.ts 套件的环境前提。

扩展点

新增测试时如何遵循既有体系:

  1. 纯函数逻辑 → 放入 tests/*.test.ts,import 被测模块,用 describe / it + node:assert/strict,仿照 site-config-reader.test.ts。TS 文件需要 --experimental-strip-types。
  2. 架构约定守护 → 源码文本模式,顶层 await readFile(new URL("../src/...", import.meta.url)) + assert.match / assert.doesNotMatch,仿照 content-pipeline.test.mjs。
  3. 跨端契约(服务端/浏览器) → 独立脚本模式,同进程内复刻两端实现互验,仿照 crypto.test.mjs;若涉及必须与源文件同步的常量,务必保留 // 从源文件复制的常量(必须与 xxx 保持同步) 式注释。
  4. 纳入默认测试集 → 把文件路径追加到 package.json 中 test 脚本的显式清单里(注意保持 && 串联结构)。

约束提醒:不要引入第三方测试框架;所有既有套件的可运行前提都是"只有 Node"。

相关链接