测试体系
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 增强功能被重构移除、字体加载方式发生回退。针对这一特点,测试体系形成了三个鲜明的设计取向:
- 零依赖:不引入 Jest / Vitest / Mocha 等任何第三方测试框架,直接使用
node:test与node:assert/strict。tests/crypto.test.mjs的文件头注释明确声明了这一点:
1/**
2 * 加密系统端到端测试
3 * 运行方式: node tests/crypto.test.mjs
4 * 无需任何测试框架依赖
5 */零依赖带来的直接收益是:CI 环境无需安装额外包、测试可在任意具备 Node 的环境中运行、不存在测试框架版本与项目工具链(Biome、Astro)的兼容性问题。
-
源码即被测对象:相当一部分测试不执行被测代码,而是把源码文件当作文本读入,用正则断言其包含(或明确不包含)特定模式。这是一种「回归守护」性质的测试——锁定架构约定,防止实现回潮或漂移。
-
跨端互操作性用复刻法验证:对加密这类「服务端加密、浏览器端解密」的跨端契约,测试在同一个进程内分别复刻服务端实现与客户端内联脚本,验证两者字节级兼容。
架构
架构说明:
- 执行入口:
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 中与测试/验证相关的脚本如下:
| 脚本 | 命令 | 作用 |
|---|---|---|
test | node --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 个独立加密测试 |
check | astro check | Astro 组件/模板的类型诊断 |
type-check | tsc --noEmit | TypeScript 全量类型检查 |
build | node scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs | 构建链内嵌两道验证门 |
check-fonts | node scripts/check-font-loading.mjs | 单独运行字体加载检查 |
值得注意的设计点:默认 npm test 命令显式列出要跑的测试文件,而不是依赖 node --test 的目录自动发现。这样默认测试集是精确可控的;其余套件(如 site-config-reader.test.ts、content-pipeline.test.mjs)按需通过同样的运行器手动执行:
node --experimental-strip-types --test tests/site-config-reader.test.ts
node --test tests/content-pipeline.test.mjs测试套件全景
tests/ 目录下的全部套件及其所属模式:
| 测试文件 | 模式 | 被测对象 | 核心断言 |
|---|---|---|---|
markdown-enhancements.test.mjs | node:test + 源码文本守护 | Markdown 渲染配置 | Markdown 增强能力(Wiki Links 等)未被移除/回退 |
layout-regressions.test.mjs | node:test + 源码文本守护 | 布局组件 | 布局回归防线,锁定结构约定 |
image-loading.test.mjs | node:test + 源码文本守护 | 图片加载实现 | 图片加载方式符合约定 |
music-player-loading.test.mjs | node:test + 源码文本守护 | 音乐播放器组件 | 播放器加载路径符合约定 |
crypto.test.mjs | 独立脚本 + 复刻互验 | 加密体系(服务端加密 / 客户端解密) | AES-256-GCM 契约两端字节级兼容 |
content-pipeline.test.mjs | node:test + 源码文本守护 | content-pipeline-fixture.mdx、rss.xml.ts、atom.xml.ts、feed-data.ts | 内容管线夹具覆盖面足够广;RSS/Atom 共用同一个渲染器 |
site-config-reader.test.ts | node:test + 纯函数单测 | scripts/read-site-config.mjs 的 extractBlock / matchInBlock | 配置块解析、覆盖优先级、块边界隔离 |
config-overrides.test.ts | node:test | 配置覆盖机制 | 覆盖文件与默认配置的合并语义 |
content-links.test.ts | node:test | 内容链接处理 | 链接生成约定 |
feed-content.test.ts | node:test | Feed 内容工具 | Feed 内容抽取逻辑 |
post-card-content.test.ts | node:test | 文章卡片组件逻辑 | 卡片内容组装 |
post-cover-source.test.ts | node:test | 封面图来源选择 | 封面来源优先级 |
post-date-utils.test.ts | node:test | 日期工具函数 | 日期格式化/解析 |
mermaid-interactions.test.mjs | node:test + 源码文本守护 | Mermaid 交互脚本 | Mermaid 图表交互行为未被破坏 |
font-loading.test.mjs | node:test + 源码文本守护 | 字体加载实现 | 字体加载方式符合约定(与构建期 check-font-loading.mjs 呼应) |
命名约定:所有测试文件以 .test.mjs(无类型)或 .test.ts(有类型,需 --experimental-strip-types)结尾,与 scripts/ 下的运维脚本区分开。
核心模式一:声明式单元测试(node:test)
绝大多数套件采用 Node 内置测试运行器的 describe / it 声明式写法,断言统一使用 node:assert/strict。以配置解析函数的单元测试为例:
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`;这段代码体现了三个关键实践:
- 直接 import 被测模块:被测对象是
scripts/read-site-config.mjs导出的纯函数extractBlock/matchInBlock,测试通过模块导入执行真实实现,而非文本匹配。 - 内联夹具(inline fixture):
DEFAULTS常量是真实上游默认配置的"缩影",字段齐全、块顺序固定。将夹具内联进测试文件,既避免测试依赖真实配置文件的内容变化,又能精确控制输入形态。 - 宽松模式:
--experimental-strip-types剥除类型后,.ts测试文件可在纯 Node 进程中运行。
覆盖的典型行为(节选自同一文件):
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 });这里专门测了一个边界情况:正则在某个配置块内找不到目标时,绝不能越界匹配到相邻块(anime 为空块时不能把 font.mode 误读为番剧模式)。这类「负向断言」是文本解析类逻辑最容易出错的地方,也是该套件的测试重点。
设计意图:read-site-config.mjs 会被 predev / prebuild 等脚本在每次构建前调用,解析结果直接影响站点行为;把它当作纯函数单测对象,可以在毫秒级验证其解析语义,无需启动 Astro。
核心模式二:源码文本回归守护测试
这是 Mizuki 测试体系中最具特色的一类。测试不执行被测代码,而是用 node:fs/promises.readFile 把源码读成字符串,再用正则做包含/排除断言。content-pipeline.test.mjs 是典型:
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);注意两个细节:
- 顶层
await:.mjs文件支持顶层 await,模块加载时即读取源码,describe内部直接使用。 - 基于
import.meta.url的相对路径:new URL("../src/...", import.meta.url)使测试可以从任意工作目录运行,不依赖 CWD。
随后是两个守护性断言:
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 });这条测试锁定的架构约定是:RSS 与 Atom 两个 Feed 路由必须共用同一个内容渲染器(getFeedContentItems),且不允许直接出现 MarkdownIt 或 markdownParser.render——防止有人为了"修个 bug"在某一个路由里私自引入旧的独立渲染路径,导致两个 Feed 内容格式漂移。assert.doesNotMatch 负向断言在这里承担了「禁止回潮」的角色。
另一条断言则验证夹具文章覆盖了内容管线的全部关键特性:
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-fixture.mdx 是一篇专门为测试而存在的文章,其价值不在于内容,而在于它把 MDX 组件、callout 语法(:::note)、Wiki Links([[guide]])、代码分组(::: code-group)、化学公式(\ce{)、图片等所有内容管线特性集中在一处。本断言确保夹具不被删减——一旦有人清理掉夹具中的某个特性,测试立刻失败,提醒"该特性在夹具中失去了覆盖"。这类测试与可视化冒烟检查(人工浏览该夹具页面)配合使用。
适用边界:文本守护测试验证的是「结构约定存在」,不验证运行时行为,因此它适合锁定架构决策;行为正确性仍需依靠纯函数单测与端到端复刻测试补足。
核心模式三:独立脚本式跨端端到端测试
crypto.test.mjs 验证的是加密体系的跨端契约:服务端在构建期用 Node crypto 加密内容,浏览器端用 WebCrypto crypto.subtle 解密。两端运行时不同,契约必须字节级一致。该测试采用「复刻互验」策略,且不使用 node:test,而是一个自包含脚本:
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-utils.ts):
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}客户端解密的复刻(对应 PasswordProtection.astro 的内联脚本):
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}测试主体使用自研的 assert 辅助函数与 passed / failed 计数器,在进程退出前汇总通过/失败数量——这就是它不依赖任何框架也能输出测试报告的方式:
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-utils.ts 保持同步。这是一种刻意的取舍——通过复刻而非 import 内联脚本(浏览器端脚本嵌在 .astro 文件中,无法被 Node 直接 import),换取了对 WebCrypto 路径的真实执行;代价是常量漂移风险需要靠注释纪律约束。若 crypto-utils.ts 中的参数(如迭代次数)变更而测试常量未同步,测试仍会通过但已不再反映真实契约。
核心流程:一次 npm test 的执行时序
流程要点:
- 两段串联:
&&保证只有第一阶段(node:test 套件)全部通过后才进入第二阶段(独立加密测试)。这样加密契约测试排在整个测试链的最后执行。 - 模块加载即准备:node:test 套件在模块加载阶段(顶层 await)就完成源码读取,
describe注册与断言执行由运行器统一调度。 - 退出码聚合:独立脚本通过计数器把多个用例的结果汇总成一个退出码,保持与
&&链的兼容性。
构建期守护脚本
除了 tests/ 目录,build 脚本内嵌了两道验证门,它们检查的是构建产物而非源码文本,与测试体系互补:
"build": "node scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs",| 守护脚本 | 执行时机 | 检查对象 |
|---|---|---|
check-global-style-loading.mjs | astro build 完成后、pagefind 之前 | 全局样式在产物中被正确加载 |
check-font-loading.mjs | pagefind 索引生成后(构建链末尾) | 字体在产物中被正确加载(可用 npm run check-fonts 单独运行) |
设计意图:把验证放在「产物已生成、后续步骤尚未执行」的位置,可以在样式/字体回退的第一时间中止构建链,避免带着缺陷产出最终站点。这些脚本与 tests/font-loading.test.mjs(源码层面的字体加载守护)形成"源码 + 产物"双层防线。
使用示例
运行默认测试集
npm test等价于 package.json 中的两段串联命令(见上文「执行入口与命令链」)。任何一个阶段失败,整体退出码非零,CI 可直接据此判定。
运行单个测试文件
由于 npm test 显式列出文件清单,其余套件按需单独执行:
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 也能执行,但不会得到运行器的汇总报告。
运行独立脚本式测试
node tests/crypto.test.mjs无任何额外标志,输出由脚本内的 passed / failed 计数器产生。
新增一个源码文本守护测试的最小模板
结合仓库中既有套件的写法,新增守护测试只需:
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-types | Node CLI 标志 | 开启(仅第一阶段) | 允许直接运行 .test.ts,剥除类型注解 |
--test | Node 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.ts | anime: {} + font.mode 场景下的负向断言 |
| 覆盖文件缺少整个配置块 | site-config-reader.test.ts | 覆盖文件只含 title 时回退默认值 |
| 所有来源都没有目标块 | site-config-reader.test.ts | 断言返回 null 由调用方兜底 |
| 加密验证前缀不匹配 | crypto.test.mjs | VERIFY_PREFIX 校验抛出 Verification prefix mismatch |
| Feed 路由引入私有渲染路径(回潮) | content-pipeline.test.mjs | assert.doesNotMatch 禁止 MarkdownIt / markdownParser.render |
| 内容夹具特性被删减 | content-pipeline.test.mjs | 对夹具 8 个特性逐一正则断言 |
并发与执行顺序
- 每个测试文件在独立的 Node 进程参数中列出,运行器按声明顺序执行;源码读取发生在模块加载阶段(顶层 await),与用例执行解耦。
- 测试均为只读操作(读源码文件、内存中运算),不写任何文件、不依赖网络、不依赖外部服务,因此天然无并发副作用,可安全地在 CI 中并行调度多个文件。
crypto.test.mjs的 PBKDF2 迭代(100000 次)是整个测试链中最耗时的 CPU 计算点,属于已知开销。
已知局限
- 常量漂移风险:
crypto.test.mjs复制的CRYPTO_CONSTANTS依赖人工同步,无机制强制(见上文风险提示)。 - 文本守护不验证运行时行为:正则只能证明"约定存在",不能证明 Markdown 渲染结果正确;行为验证依赖夹具页面的人工/可视化冒烟。
- 默认
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套件的环境前提。
扩展点
新增测试时如何遵循既有体系:
- 纯函数逻辑 → 放入
tests/*.test.ts,import被测模块,用describe/it+node:assert/strict,仿照site-config-reader.test.ts。TS 文件需要--experimental-strip-types。 - 架构约定守护 → 源码文本模式,顶层
await readFile(new URL("../src/...", import.meta.url))+assert.match/assert.doesNotMatch,仿照content-pipeline.test.mjs。 - 跨端契约(服务端/浏览器) → 独立脚本模式,同进程内复刻两端实现互验,仿照
crypto.test.mjs;若涉及必须与源文件同步的常量,务必保留// 从源文件复制的常量(必须与 xxx 保持同步)式注释。 - 纳入默认测试集 → 把文件路径追加到
package.json中test脚本的显式清单里(注意保持&&串联结构)。
约束提醒:不要引入第三方测试框架;所有既有套件的可运行前提都是"只有 Node"。