shadcn/ui tests 包实战指南:用本地 Registry 构建 CLI 集成测试的完整方案
shadcn/ui 仓库中的 packages/tests 是一组面向 shadcn CLI 的集成测试:它把真实 CLI 可执行文件跑在本地 registry 上,针对 Next.js、Vite、Remix 等项目模板做端到端验证,确保 init、add、apply 等命令生成的文件与配置符合预期。读完本篇,你将理解这套测试从 fixture 拷贝、子进程调用 CLI、到断言 components.json 与 CSS 输出的完整链路,并能按同样的模式为 CLI 新增集成测试。
一、集成测试包的定位
根据 packages/tests/README.md 的说明,该包的目标是:
This package contains integration tests that verify the shadcn CLI works correctly with a local registry. The tests run actual CLI commands against test fixtures to ensure files are created and updated properly.
也就是说,它测的不是某个纯函数,而是**“CLI 二进制 + 本地 registry 服务 + 目标项目脚手架”三方协作后的磁盘结果**。从 packages/tests/package.json 可以看到其依赖结构印证了这一定位:
"shadcn": "workspace:*":直接以 workspace 依赖引用本仓库的 CLI 包,保证测试跑的是当前源码构建产物;execa:以子进程方式调用 CLI;fs-extra:fixture 拷贝、文件断言;rimraf:清理临时测试目录;vitest+vite-tsconfig-paths:测试运行器与 TS 路径解析。
测试用例按 CLI 命令划分为七个文件,位于 packages/tests/src/tests/:add.test.ts、apply.test.ts、init.test.ts、registries.test.ts、search.test.ts、typeset.test.ts、view.test.ts,与 CLI 的核心子命令一一对应。
二、运行测试:命令与前置条件
README 给出的官方运行方式是(在 workspace 根目录执行):
pnpm tests:test
而从仓库根目录 package.json 的实际编排来看,完整的本地验证流程由 test 脚本串联:
"test": "pnpm --filter=v4 registry:build && start-server-and-test v4:dev http://localhost:4000 test:dev",
"test:dev": "turbo run test --force"
可以拆解出三个关键前提:
- 先构建 registry 产物:
pnpm --filter=v4 registry:build,把apps/v4的 registry 数据构建出来; - 启动 v4 开发服务器:
start-server-and-test v4:dev http://localhost:4000 ...会等待http://localhost:4000就绪后再执行测试,这也就是测试默认 registry 地址http://localhost:4000/r的来源(见下文getRegistryUrl); - turbo 调度:
turbo run test --force触发各包 test 任务;turbo.json 中test任务声明了"dependsOn": ["^build"]且"cache": false,即测试始终依赖上游构建产物且不命中缓存。
另一个隐含前提是 CLI 本体已构建:setup.ts 会显式等待 packages/shadcn/dist/index.js 存在(细节见第五节)。
三、核心测试工具:fixtures 与子进程调用
所有测试共用 packages/tests/src/utils/helpers.ts 中的三个基础设施。
3.1 从 fixture 创建隔离测试目录
createFixtureTestDirectory 的逻辑是:把 packages/tests/fixtures/ 下的指定 fixture 整体复制到一个带唯一后缀的临时目录,避免用例之间互相污染:
const FIXTURES_DIR = path.join(__dirname, "../../fixtures")
const SHADCN_CLI_PATH = path.join(__dirname, "../../../shadcn/dist/index.js")
const TEMPLATES_DIR = path.join(__dirname, "../../../../templates")
export async function createFixtureTestDirectory(fixtureName: string) {
const fixturePath = path.join(FIXTURES_DIR, fixtureName)
const uniqueId = `${process.pid}-${randomUUID().substring(0, 8)}`
let testDir = path.join(TEMP_DIR, `test-${uniqueId}-${fixtureName}`)
await fs.ensureDir(testDir)
await fs.copy(fixturePath, testDir)
return testDir
}
当前可用的 fixture 覆盖了主流框架形态(见 packages/tests/fixtures/):
| Fixture | 覆盖场景 |
|---|---|
next-app |
标准 Next.js 项目(app/ 目录结构) |
next-app-imports / next-app-init |
已带 src/ 导入风格或已 init 过的 Next.js 项目 |
vite-app / vite-monorepo-imports |
Vite 单包与 monorepo 形态(含 pnpm-workspace.yaml) |
remix-app |
未被模板覆盖的框架,用于验证降级路径 |
no-framework |
空目录边界场景 |
registry/*.json |
自定义 registry 条目样本,如 example-item.json、example-style.json、example-target-aliases.json 等 |
3.2 以子进程运行真实 CLI
runCommand 是全部 CLI 调用的底层通道,它用 execa 以 node packages/shadcn/dist/index.js ... 的方式启动 CLI,并对环境做了刻意约束:
const childProcess = execa("node", [SHADCN_CLI_PATH, ...args], {
cwd,
env: {
...process.env,
FORCE_COLOR: "0", // 关闭 ANSI 颜色,便于对 stdout 做字符串断言
CI: "true", // 模拟 CI 行为
...options?.env,
},
input: options?.input, // 支持注入交互输入
reject: false, // 失败不抛异常
timeout: options?.timeout ?? 60000, // 默认 60s 超时
})
配合 reject: false,调用方拿到统一的 { stdout, stderr, exitCode } 结构,因此测试既能断言成功路径,也能断言失败路径(如非法模板名返回 exitCode === 1 且 stdout 含 "Invalid template")。
外层封装 npxShadcn 则负责注入两个关键环境变量:
export async function npxShadcn(cwd, args, { debug = false, input, timeout } = {}) {
const result = await runCommand(cwd, args, {
env: {
REGISTRY_URL: getRegistryUrl(),
SHADCN_TEMPLATE_DIR: TEMPLATES_DIR,
},
input,
timeout,
})
if (debug) console.log(result)
return result
}
其中 getRegistryUrl() 默认返回 http://localhost:4000/r,可用 REGISTRY_URL 环境变量覆盖;SHADCN_TEMPLATE_DIR 指向仓库根 templates/ 目录——这正是 CLI 创建新项目(init --name xxx)时所用脚手架的来源,与根目录 templates/next-app、templates/vite-app 等模板目录一一对应。
此外 helpers 还导出 cssHasProperties(cssContent, checks):对 CSS 文本按选择器提取代码块,逐一校验 property: value; 是否存在,是断言主题变量落地情况的利器(init 测试中大量使用,见第六节)。
四、测试夹具全景:fixtures 与 registry 样本
如第三节所述,packages/tests/fixtures/ 是测试的“被测项目库”。每个 fixture 都是一个精简但可被 CLI 识别的真实项目骨架,例如:
- packages/tests/fixtures/next-app/ 含
app/globals.css、app/layout.tsx、next.config.ts、package.json、postcss.config.mjs、tsconfig.json,模拟一个干净的 Next.js + Tailwind 项目,是init测试的主力夹具; - packages/tests/fixtures/vite-monorepo-imports/ 则带有
pnpm-workspace.yaml与apps/web、packages/ui两级结构,用于验证 monorepo 下components.json与 workspace 别名(@workspace/ui/...)的处理; - packages/tests/fixtures/registry/ 中的 10 个 JSON 样本对应自定义 registry 的各类条目:
example-at-property.json、example-component.json、example-env-vars.json、example-item-to-root.json、example-style.json、example-target-alias-child.json等,供registries.test.ts验证远端 registry 项的解析与落地。
这种“fixture 即场景”的划分,让每个 describe 块可以明确声明自己测的是哪种框架形态。
五、vitest 配置与全局预热:解决慢冷启动问题
packages/tests/vitest.config.ts 的配置值得逐条理解:
test: {
testTimeout: 120000,
hookTimeout: 120000,
globals: true,
environment: "node",
globalSetup: ["./src/utils/setup.ts"],
maxConcurrency: 4,
isolate: false,
}
testTimeout/hookTimeout均放宽到 120s:单个用例要完整跑一遍 CLI 子进程;maxConcurrency: 4+isolate: false:控制并发并复用同一进程,因为每个用例都在独立临时目录工作,不需要沙箱隔离。
真正体现工程功力的是全局设置 packages/tests/src/utils/setup.ts。它在所有测试开始前做三件事,全部是为了对抗 Next.js dev server 的冷编译延迟:
- 等待 CLI 二进制就绪:v4 的 dev 脚本会在后台构建 shadcn 包,CI 上可能出现“服务器已起但
packages/shadcn/dist/index.js尚未构建完”的竞态,因此waitForCondition会轮询该路径(最长 60s); - 预热
/init动态路由:CLI 第一个请求打到/init路由,冷 dev server 首次访问需现场编译(注释中实测约 1.8s),叠加 init 本身的工作量会击穿 CLI 的 30s 超时,所以 setup 主动请求一次完整的 init URL; - 预触发一次 404:CLI 会拉取可能不存在的 registry 路径(如字体文件),首次 404 会触发 Next.js 编译
/_not-found/page(约 4–5s),setup 通过请求一个不存在的font-geist.json提前消化掉这次编译。
清理阶段则用 rimraf 删除 TEMP_DIR(即 packages/tests/temp/),保证测试可重复运行。
六、内存 Registry:在本地模拟远端 registry 的全部端点
要测试“从自定义 registry 拉取资源”,不必依赖外网。packages/tests/src/utils/registry.ts 的 createRegistryServer 用 Node 原生 http 起一个默认监听 4444 端口、路径前缀 /r 的迷你 registry 服务,端点行为如下:
/r/registries.json:返回空数组[](刻意留空,让测试验证components.json中的手动 registry 配置生效);/r/icons/index:返回 lucide/radix 图标映射表;/r/colors/neutral:返回inlineColors、cssVars、cssVarsV4(oklch 色值)及inlineColorsTemplate/cssVarsTemplate,模拟真实的颜色端点;/r/styles/index与/r/styles/{style}/index:分别返回风格列表(new-york、default)与风格索引项;/r/index:返回alert-dialog、button两个registry:ui条目(内联文件内容);/r/registry及任意/r/{item}路径:按传入的 items 返回条目,并对鉴权路径做严格校验:/bearer/:校验Authorization头,token 必须为EXAMPLE_BEARER_TOKEN,否则 401;/api-key/:校验x-api-key头(EXAMPLE_API_KEY);/client-secret/:校验x-client-secret+x-client-id头;/params/:校验 URL 查询参数 token(EXAMPLE_REGISTRY_TOKEN)。
这组 401 用例让 registries.test.ts 可以在本机穷举 CLI 对四种鉴权方式(bearer / api-key / client-secret / query 参数)的处理逻辑。配套的 configureRegistries(fixturePath, payload) 工具则负责向 fixture 的 components.json 写入/更新 registries 字段,模拟用户在项目中登记私有 registry 的操作。
七、用例示例:init 测试如何断言端到端结果
packages/tests/src/tests/init.test.ts 是最能体现测试风格的文件,它覆盖 next-app、vite-app、自定义 style、不支持的框架、模板标志、--name 建项目、monorepo、RTL、--force 等场景。挑几个代表性用例说明断言层次:
默认配置落盘:对 next-app fixture 执行 init --defaults 后,逐层验证——
const componentsJson = await fs.readJson(componentsJsonPath)
expect(componentsJson).toMatchObject({
style: "base-nova",
rsc: true,
tsx: true,
tailwind: { config: "", css: "app/globals.css", baseColor: "neutral", cssVariables: true },
aliases: { components: "@/components", utils: "@/lib/utils", ui: "@/components/ui", lib: "@/lib", hooks: "@/hooks" },
})
expect(await fs.pathExists(path.join(fixturePath, "lib/utils.ts"))).toBe(true)
再对 app/globals.css 内容做字符串断言(@layer base、:root、.dark、tw-animate-css、--background、--foreground)。
CSS 变量精检:对自定义 style registry(在 4445 端口起 createRegistryServer,提供 style、style-extended、style-extend-none 三个条目),测试用 cssHasProperties 精确校验 @theme inline、:root、.dark 三个选择器块内的每一个变量值,例如验证 style 继承链中 --foo-var 是否按“子覆盖父”合并为 2rem——这是对 registry style extends 语义的白盒级验证。
别名改写:vite-app fixture 使用 #custom/... 导入别名,测试断言 init --defaults alert-dialog 后,src/components/ui/alert-dialog.tsx 内的 import 语句已被改写为 import { Button } from "#custom/components/ui/button",验证 CLI 能尊重项目已有别名约定。
失败与边界路径:
-t invalid→exitCode为 1,stdout 含Invalid template;- 已弃用的
--src-dir→ 以未知选项拒绝(exit 1); --name新建项目时特意把目录建在os.tmpdir()下,代码注释说明原因:“This prevents pnpm from detecting the monorepo workspace root”;- 已存在
components.json时,init 失败会回滚备份:测试验证报错后原配置原样恢复且不留.bak;--force重新 init 时用户自定义的registries字段被保留。
monorepo 场景(CI 上通过 itIfNotCi 跳过):init --name ... -t next --monorepo --preset nova --base radix 后,断言 packages/ui/components.json 的 style 为 radix-nova、iconLibrary 为 lucide,且 apps/web 的别名被解析为 @workspace/ui/lib/utils 等 workspace 路径,CSS 主题变量写入 packages/ui/src/styles/globals.css。
八、按 README 模式编写新的集成测试
README 给出的最小用例骨架如下(注意其中 import 路径相对于测试文件所在位置):
import {
createFixtureTestDirectory,
fileExists,
npxShadcn,
} from "../utils/helpers"
describe("my test suite", () => {
it("should do something", async () => {
// Create a test directory from a fixture
const testDir = await createFixtureTestDirectory("next-app")
// Run CLI command
await npxShadcn(testDir, ["init", "--base-color=neutral"])
// Make assertions
expect(await fileExists(path.join(testDir, "components.json"))).toBe(true)
})
})
结合当前源码需要补充三点实操要点:
- 断言文件存在时,现有测试普遍直接借助
fs-extra:expect(await fs.pathExists(...)).toBe(true)(见 init.test.ts)。README 示例中的fileExists辅助在当前 helpers.ts 中并未导出,写新用例时建议以fs.pathExists为准,或自行在 utils 中补齐; - 命令参数风格:现网用例多用
["init", "--defaults"]、["init", "--defaults", "button"]这类组合,需要交互输入时用npxShadcn(dir, args, { input: "..." })注入; - 自定义 registry:先用
createRegistryServer(items, { port })起服务并在beforeAll中start()、afterAll中stop(),再让 CLI 指向http://localhost:{port}/r/xxx.json;需要把该 registry 登记进项目配置时用configureRegistries(fixturePath, { name: { url: ... } })。
新测试文件放在 packages/tests/src/tests/ 下(命名为 xxx.test.ts),vitest 会自动拾取;如果单个用例涉及建项目这类重操作,可参考现有做法将超时提到 120s–300s(通过 npxShadcn 的 timeout 选项,其默认值为 60s)。
九、小结:这套集成测试的设计要点
从 packages/tests 的实现可以归纳出 shadcn CLI 集成测试的五个设计决策,均可在源码中直接对照:
- 测真二进制:通过
workspace:*依赖 +execa执行packages/shadcn/dist/index.js,而非 mock CLI 内部逻辑(helpers.ts); - 环境即数据:
REGISTRY_URL与SHADCN_TEMPLATE_DIR通过环境变量注入,使同一套测试既能对本地 v4 服务(http://localhost:4000/r)运行,也能指向任意兼容 registry; - 隔离与幂等:每个用例从 fixture 复制到
pid + UUID命名的独立目录,全局 teardown 用rimraf清场(setup.ts); - 对抗冷启动:全局 setup 显式等待 CLI 产物、预热
/init路由与 404 路由,把 Next.js dev 环境的编译抖动挡在用例之外(setup.ts); - 失败也是一等断言对象:
reject: false+ 统一的{ stdout, stderr, exitCode }返回结构,让 401 鉴权失败、非法模板、弃用参数等负向路径都能被稳定验证(registry.ts、init.test.ts)。
掌握以上链路后,你既能读懂每个断言背后 CLI 的契约(配置字段、别名改写、CSS 变量合并、鉴权处理),也能按同样模式为新的 CLI 命令补充可重复、可隔离的集成测试。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00