- 开发工具
- 文档
【免费下载链接】quarto-cli
Open-source scientific and technical publishing system built on Pandoc.
本篇指南以 Quarto CLI 仓库中的测试基础设施为核心,系统讲解如何为这一基于 Deno 构建的科学出版工具编写可靠的 smoke 测试。你将掌握testQuartoCmd/testRender两种入口的使用边界、验证器与路径辅助函数的完整语义、Dev 模式与 Binary 模式的行为差异、引擎环境与跨平台陷阱,以及 YAML 内嵌测试的转义规则——全部知识点均有仓库源码与真实测试文件作为依据。
测试架构总览:四个核心文件
Quarto CLI 的测试体系建立在四个相互协作的文件之上(tests/):
| 文件 | 职责 |
|---|---|
| tests/test.ts | 核心测试运行器,导出testQuartoCmd、unitTest、TestContext等基础设施 |
| tests/quarto-cmd.ts | Quarto 调用分发,runQuarto()统一处理进程内 Dev 模式与子进程 Binary 模式 |
| tests/verify.ts | 验证辅助函数,如fileExists、noErrors、ensureHtmlElements等 |
| tests/utils.ts | 通用工具,如docs()、outputForInput()、withTempDir() |
testQuartoCmd是 smoke 测试的统一入口(tests/test.ts),它会自动以quarto <cmd> <args>生成测试名,调用runQuarto()执行命令,并通过throwOnFailure: false把失败留给验证器从日志中报告。日志默认以json-stream格式写入临时文件,验证器(Verify)随后逐条解析这些 JSON 记录(见 readExecuteOutput)。
Dev 模式与 Binary 模式
runQuarto()通过是否存在QUARTO_TEST_BIN环境变量来决定运行方式(tests/quarto-cmd.ts):
- Dev 模式(默认):直接以进程内方式调用从 src/quarto.ts 导入的
quarto()入口函数,无退出码概念,失败通过 Promise 拒绝(reject)来表达。 - Binary 模式:
QUARTO_TEST_BIN指向 checkout 之外已安装的 Quarto 发行版。runQuarto()以子进程方式启动它,并通过--log、--log-format json-stream、--log-level参数捕获 JSON 流日志;启动前会用buildBinaryEnv()清除 dev 树环境变量(清单见 tests/binary-mode-strip-env.txt)。
assertTestBinary()会预检该二进制:拒绝报告99.9.9dev 版本哨兵的二进制(说明它解析到了 TS 源码而非已构建发行版),也支持用QUARTO_TEST_EXPECTED_VERSION校验精确版本(tests/quarto-cmd.ts)。Binary 模式下默认只运行tests/smoke/目录下的测试。CI 行为与设计决策详见 llm-docs/built-version-testing-architecture.md。
写 smoke 测试时应遵循的纪律:
- 一律通过
testQuartoCmd()或runQuarto()调用 Quarto,不要直接从src/quarto.ts导入quarto()。 - 需要在测试中直接启动子进程时,用
quartoDevCmd()解析可执行文件路径(tests/utils.ts),并传入quartoSpawnEnvOptions()(tests/quarto-cmd.ts)保证 Binary 模式下的环境清洗。 TestContext.requiresDevQuarto: true只用于确实依赖进程内实现细节的测试(Binary 模式下会被自动忽略,见 tests/test.ts);能用单元测试解决的就不要写 smoke 测试。
常用测试模式一:简单渲染测试
最简形式
测试单个文档渲染并自动清理:
import { docs } from "../../utils.ts"; import { testRender } from "./render.ts"; // 最简形式——只渲染并验证输出已创建 testRender(docs("test-plain.md"), "html", false);testRender()定义在 tests/smoke/render/render.ts,它会自动向验证列表追加outputCreated(检查日志中存在 "Output created:" 消息且输出文件存在)以及noSupportingFiles/hasSupportingFiles断言,并在 teardown 中调用cleanoutput()清理输出与_files/目录。真实示例见 tests/smoke/render/render-plain.test.ts。
带附加验证器
import { docs, outputForInput } from "../../utils.ts"; import { testRender } from "./render.ts"; import { ensureHtmlElements } from "../../verify.ts"; const input = docs("minimal.qmd"); const output = outputForInput(input, "html"); testRender(input, "html", true, [ ensureHtmlElements(output.outputPath, [], ["script#quarto-html-after-body"]), ]);真实示例见 tests/smoke/render/render-minimal.test.ts。ensureHtmlElements使用仓库内置的deno-dom解析 HTML(src/core/deno-dom.ts),第一个数组是必须存在的选择器,第二个是必须不存在的选择器。
关键点:
testRender()自动处理输出验证与清理,QUARTO_TEST_KEEP_OUTPUTS环境变量可保留输出用于调试(tests/smoke/render/render.ts)。noSupporting参数要与实际输出匹配:true——真正自包含的 HTML(无_files/目录,全部内联),仅用于self-contained: true之类的格式;false——存在支持文件目录的 HTML(OJS 运行时、widget 依赖、图表等)。- 绝大多数 HTML 输出应使用
false。
- 附加验证器通过数组参数传入(可选)。
常用测试模式二:项目渲染测试
网站类项目渲染是 smoke 测试的高频场景:
import { docs } from "../../utils.ts"; import { join } from "../../../src/deno_ral/path.ts"; import { existsSync } from "../../../src/deno_ral/fs.ts"; import { testQuartoCmd } from "../../test.ts"; import { fileExists, pathDoNotExists, noErrors } from "../../verify.ts"; const projectDir = docs("project/my-test"); // 通过 docs() 得到相对路径 const outputDir = join(projectDir, "_site"); // 网站项目追加 _site 输出目录 testQuartoCmd( "render", [projectDir], [ noErrors, // 检查无错误 fileExists(join(outputDir, "index.html")), // 验证期望的文件存在 pathDoNotExists(join(outputDir, "ignored.html")), // 验证文件不存在 ], { teardown: async () => { if (existsSync(outputDir)) { await Deno.remove(outputDir, { recursive: true }); } }, }, );关键点:
- 用
docs()辅助函数基于tests/docs/构造相对路径。 - 网站项目输出到
_site子目录;用join()构造绝对路径用于文件验证。 - 在
teardown中清理输出目录。 - 真实示例见 tests/smoke/project/project-website.test.ts 与 tests/smoke/project/project-ignore-dirs.test.ts。
常用测试模式三:性能预算(渲染超时)
testQuartoCmd默认给每次渲染 10 分钟(600000ms)超时(tests/quarto-cmd.ts)。如果测试是在守卫性能回归(某个渲染不应挂起),应通过TestContext.timeout(毫秒)设置更紧的预算,让回归快速失败而不是耗满 10 分钟:
testQuartoCmd("render", [projectDir], [noErrors /*, ... */], { timeout: 120000, // 健康渲染远低于此值;挂起会触发 setup: () => { writeProject(); // 在临时目录生成 fixture 项目 return Promise.resolve(); }, teardown: () => { safeRemoveSync(projectDir, { recursive: true }); return Promise.resolve(); }, });关键点:
- 给慢机器留足余量;同时为该修复配一个确定性的单元测试。
- Dev(进程内)模式下,超时的渲染不会被 harness 杀死(超时只是 reject Promise),因此在 Windows 上可能仍占用输出目录;请在 teardown 中使用
safeRemoveSync,并把清理视为尽力而为。 - Binary 模式(
QUARTO_TEST_BIN)下,超时时会杀死派生的进程树(killProcessTree通过 Windowstaskkill /T /F或 Unixpgrep -P枚举后 SIGKILL,见 tests/quarto-cmd.ts),但 kill 仍是尽力而为——保持同样的防御性 teardown。 - 超时错误以
QuartoTimeoutError区分于普通失败,renderCancelled标识渲染是否已知被停止(tests/quarto-cmd.ts)。
常用测试模式四:扩展模板测试
测试quarto use template:
import { testQuartoCmd } from "../../test.ts"; import { fileExists, noErrorsOrWarnings, pathDoNotExists, } from "../../verify.ts"; import { join } from "../../../src/deno_ral/path.ts"; import { ensureDirSync } from "../../../src/deno_ral/fs.ts"; const tempDir = Deno.makeTempDirSync(); // 创建 mock 模板源 const templateSourceDir = join(tempDir, "template-source"); ensureDirSync(templateSourceDir); Deno.writeTextFileSync(join(templateSourceDir, "template.qmd"), "..."); Deno.writeTextFileSync(join(templateSourceDir, "config.yml"), "..."); const templateFolder = "my-test-template"; const workingDir = join(tempDir, templateFolder); ensureDirSync(workingDir); testQuartoCmd( "use", ["template", templateSourceDir, "--no-prompt"], [ noErrorsOrWarnings, fileExists(`${templateFolder}.qmd`), // 相对路径——模板文件被重命名为文件夹名 pathDoNotExists(join(workingDir, "README.md")), // 绝对路径——被排除的文件 ], { cwd: () => workingDir, // 设置工作目录 teardown: () => { try { Deno.removeSync(tempDir, { recursive: true }); } catch { // 忽略清理错误 } return Promise.resolve(); }, }, );关键点:
- 用
Deno.makeTempDirSync()获得隔离的测试环境。 - 创建带测试文件的 mock 模板源。
- 模板文件会被重命名为目标目录名。
- 用
cwd()函数设置命令执行的工作目录。 - 清理整个临时目录(含源文件)。
- 验证位于
cwd()内的文件时使用相对路径。真实示例见 tests/smoke/use/template.test.ts。
常用测试模式五:工作目录敏感测试
某些测试依赖特定工作目录(例如复现依赖进程 cwd 的 bug)。不要在测试体内调用Deno.chdir()——它会修改进程全局 cwd,泄漏到同一进程中的其他测试。应使用TestContext选项:harness 在测试前切换 cwd,测试后恢复(tests/test.ts、tests/test.ts):
const workingDir = Deno.makeTempDirSync(); unitTest("runs from workingDir", async () => { // 此处 cwd 是 workingDir(由 harness 设置) }, { setup: () => { Deno.writeTextFileSync(".env.example", "..."); return Promise.resolve(); }, cwd: () => workingDir, teardown: () => { try { Deno.removeSync(workingDir, { recursive: true }); } catch { /* 尽力而为 */ } return Promise.resolve(); }, });关键点:
- harness先调用
cwd()再调用setup(),所以cwd()执行时目录必须已存在——要在模块作用域创建目录,而不是在setup中创建。 teardown在 harness 恢复 cwd之前执行,因此 Windows 上临时目录可能仍是当前 cwd 而无法删除。请用 try/catch 包裹删除操作(尽力而为)——参考 tests/smoke/use/template.test.ts 与 tests/unit/dotenv-config.test.ts。- 若临时目录不需要作为运行目录,优先使用
withTempDir(tests/utils.ts),它在finally中创建并递归删除。 - 只需相对输入(不需要特定 cwd)的测试,可直接传相对于当前 cwd 的路径(
relative(Deno.cwd(), absFile)),完全不需要切换目录。
验证器辅助函数速查
核心验证器(tests/verify.ts)
// 输出中无错误 noErrors // 无错误或警告 noErrorsOrWarnings // 路径存在文件 fileExists(path: string) // 路径不存在 pathDoNotExists(path: string) // 路径存在文件夹 folderExists(path: string) // 目录只包含允许的路径 directoryEmptyButFor(dir: string, allowedFiles: string[])noErrors的实现按日志记录的levelName是否为error判定(tests/verify.ts);noErrorsOrWarnings额外把warn计入失败(tests/verify.ts)。仓库还提供了shouldError(期望渲染失败)、printsMessage(匹配指定级别日志)、ensureHtmlElements/ensureHtmlElementContents/ensureHtmlElementCount(DOM 断言)、ensureFileRegexMatches/ensureCssRegexMatches(文本正则)、ensureSnapshotMatches(快照对比,diff 保存在.diff文件)、ensurePdfRegexMatches(依赖 PATH 上的pdftotext)、verifyDocXDocument/verifyEpubDocument/verifyPptxDocument(解压 OOXML 检查)以及 JATS/Odt/Docx/Pptx 的 XPath 断言族,按需选用。
路径辅助函数(tests/utils.ts)
// 从 tests/docs/ 构造相对路径 docs(path: string): string // 示例:docs("project/site") → "tests/docs/project/site" // 计算输入文件的期望输出 outputForInput(input: string, to: string, projectOutDir?: string, projectRoot?: string) // 查找项目目录(向上查找 _quarto.yml/_quarto.yaml) findProjectDir(input: string, until?: RegExp) // 查找项目输出目录(_site、_book 等) findProjectOutputDir(projectdir: string)findProjectOutputDir读取项目_quarto.yml:book→_book,website→project.output-dir或默认_site,manuscript→project.output-dir或默认_manuscript,其余按project.output-dir或空字符串处理(tests/utils.ts)。outputForInput会根据目标格式推断扩展名(如pdf→.pdf、revealjs→.html、jats→.xml、typst→.pdf等,tests/utils.ts)。
输出目录模式
不同项目类型使用不同输出目录:
// 网站项目 const outputDir = join(projectDir, "_site"); // 书籍项目 const outputDir = join(projectDir, "_book"); // 手稿项目 const outputDir = join(projectDir, "_manuscript"); // 普通项目(未指定类型) // 输出直接在项目目录内 const outputDir = projectDir;测试文件组织
tests/ ├── docs/ # 测试 fixtures │ └── project/ │ └── my-test/ │ ├── _quarto.yml │ ├── index.qmd │ └── other-files.qmd ├── smoke/ # smoke 测试 │ ├── project/ │ │ └── project-my-test.test.ts │ ├── render/ │ ├── use/ │ └── ... ├── test.ts # 测试运行器 ├── verify.ts # 验证辅助函数 └── utils.ts # 通用工具函数通用模式
清理模式
始终在 teardown 中清理生成的文件:
teardown: async () => { if (existsSync(outputPath)) { await Deno.remove(outputPath, { recursive: true }); } };多测试用例
测试多个场景时,在模块级声明常量:
const tempDir = Deno.makeTempDirSync(); // 用例 1 const folder1 = "test-case-1"; const workingDir1 = join(tempDir, folder1); ensureDirSync(workingDir1); testQuartoCmd(...); // 用例 2 const folder2 = "test-case-2"; const workingDir2 = join(tempDir, folder2); ensureDirSync(workingDir2); testQuartoCmd(...);路径构造
- 绝对路径:所有路径操作使用
join()(跨平台安全)。 - 相对 docs:用
docs()辅助函数。 - 相对 cwd:在模板测试中用普通字符串或模板字面量。
仓库中的真实示例
| 场景 | 参考文件 |
|---|---|
| 最简渲染测试(无附加验证器) | tests/smoke/render/render-plain.test.ts |
| 带 HTML 元素自定义验证的渲染 | tests/smoke/render/render-minimal.test.ts |
| 目录排除模式 | tests/smoke/project/project-ignore-dirs.test.ts |
| 网站项目渲染 | tests/smoke/project/project-website.test.ts |
| 扩展模板使用 | tests/smoke/use/template.test.ts |
引擎特定测试注意事项
共享测试环境(quarto-cli 测试的关键约束)
Quarto-cli 测试基础设施为所有测试使用单一托管环境:
- Julia:
tests/Project.toml+tests/Manifest.toml - Python:
tests/.venv/(由 uv/pyproject.toml 管理) - R:
tests/renv/+tests/renv.lock
configure-test-env脚本(tests/configure-test-env.sh / tests/configure-test-env.ps1)只管理上述主环境,CI 构建依赖此结构。
不要在测试子目录中创建语言环境文件:
tests/docs/my-test/ ├── Project.toml # ❌ 错误——破坏测试基础设施 ├── .venv/ # ❌ 错误——破坏测试基础设施 ├── renv.lock # ❌ 错误——破坏测试基础设施 └── test.qmd为什么会失败:
- Julia 向上查找
Project.toml并使用找到的第一个。 - Python/R 若存在本地环境会优先使用。
- CI 脚本不会配置这些本地环境。
- 测试在本地能过、在 CI 必然失败。
添加新的包依赖:
对任意引擎(Julia、Python、R),都添加到tests/主环境:
# Julia:在 tests/ 目录使用 Pkg cd tests julia --project=. -e 'using Pkg; Pkg.add("PackageName")' # 然后运行 configure 更新环境 ./configure-test-env.sh # Windows 用 .ps1 # Python:在 tests/ 目录使用 uv cd tests uv add packagename # R:编辑 tests/DESCRIPTION,然后 cd tests Rscript -e "renv::install(); renv::snapshot()"注意:虽然 Quarto 生产使用支持文档目录中的本地Project.toml,但 quarto-cli 测试基础设施明确不支持这种模式。所有测试依赖必须放在tests/主环境中。
改变工作目录的 R 测试
R 从精确的进程 cwd 解析.Rprofile(不向上搜索父目录)。在 CI 上,rmarkdown/knitr 只存在于tests/renv的项目库中,当 cwd 为tests/时通过tests/.Rprofilesource 的renv/activate.R激活。大多数 knitr 测试从不离开tests/——它们传入相对当前 cwd 的路径而不是切换目录——因此激活自动发生。
通过TestContext.cwd()切换 cwd 的测试会失去该激活:R 子进程在tests/之外启动,包加载可能失败并报there is no package called 'rmarkdown'。开发者机器上若 rmarkdown 位于默认.libPaths(),可能掩盖这个 CI 失败。
修复:在 fixture 的 cwd 中写一个.Rprofile,让 renv 指向测试项目:
Sys.setenv(RENV_PROJECT = "<tests/ 的绝对路径>") source("<tests/ 的绝对路径>/renv/activate.R")renv/activate.R使用RENV_PROJECT作为项目根而不是当前目录。
最佳实践清单
- 始终清理:用 teardown 删除生成的文件。
- 使用辅助函数:用
docs()、fileExists()等,而不是手写检查。 - 绝对路径:所有路径构造用
join()处理平台差异。 - 测试隔离:创建文件的测试使用临时目录。
- 清晰命名:使用描述性变量名如
projectDir、outputDir、templateFolder。 - 注释意图:添加注释说明应该/不应该发生什么。
- 处理错误:用 try-catch 包裹清理,避免清理问题导致测试套件失败。
环境变量测试陷阱
Deno.env.set()修改进程全局状态。Deno 默认并行运行测试文件(同一 OS 进程),因此并发测试可能看到被修改的值。保存/恢复模式也无济于事——其他测试在修改窗口期内仍会看到该值。
| 执行模式 | 风险 | 原因 |
|---|---|---|
./run-tests.sh(默认) | 竞态条件 | 文件并行运行,共享Deno.env |
./run-parallel-tests.sh | 无 | 独立 OS 进程 |
推荐做法:通过TestContext.env传入每个测试的变量。这在两种模式下都有效,且不修改进程全局状态(harness 会把它作为 overlay 传给runQuarto)。
例外:tests/smoke/website/drafts-env.test.ts 在模块加载时设置QUARTO_PROFILE并在context.env中重复设置。Dev 模式在缓存基础 profile 时读取模块级值;Binary 模式接收 context 值。
新测试的替代方案:单元测试 env 读取函数、重构代码接受参数、或使用子进程隔离。
测试文件排除
测试文件被排除(如 AI 配置文件)时:
// 测试文件 NOT 被渲染 testQuartoCmd( "render", [projectDir], [ noErrors, fileExists(join(outputDir, "expected.html")), // 应存在 pathDoNotExists(join(outputDir, "excluded.html")), // 不应存在 ], // ... );先在没有修复时运行测试确认它失败,再验证修复后通过。
Smoke-All 测试(YAML 内嵌)
Smoke-all 测试通过_quarto.tests元数据把测试规格直接嵌入.qmd文件(tests/smoke/smoke-all.test.ts),完整文档见.claude/rules/testing/smoke-all-tests.md。
YAML 正则转义(关键规则)
核心规则:在 YAML 单引号字符串中,'\('与"\\("等价——都产生正则中的字面量\(。
常见错误:过度转义为'\\('会产生\\((两个反斜杠),导致正则失败。
_quarto: tests: pdf: ensureLatexFileRegexMatches: # 正确——YAML 单引号中单个反斜杠 - ['\(1\)', '\\circled\{1\}', "Variable assignment"] - ['\\CommentTok', '\\begin\{Shaded\}'] # 错误——过度转义(正则中产生 \\() - ['\\(1\\)', '\\\\circled\\{1\\}']YAML 转义速查表:
| 要在文件中匹配 | 单引号'...' | 双引号"..." |
|---|---|---|
\( | '\(' | "\\(" |
\begin{ | '\\begin\{' | "\\\\begin\\{" |
\\(字面量) | '\\\\' | "\\\\\\\\" |
[(正则) | '\[' | "\\[" |
建议:使用单引号字符串——它更简单,只有'本身需要转义(写成'')。
探测足够的键以暴露深层合并 bug
如果优先级测试的模板只读取被覆盖的那一个键,那么在深层合并(deep-merge)bug 下测试可能通过:被丢弃的兄弟键永远不会被解析,但没有断言会发现。
示例:用户提供的variables.quarto.language.crossref-ch-prefix: Bouquin与 Quarto 内置的format.language表在variables.quarto.language下合并。在浅层展开({ ...a, ...b })下,b.language会替换整个本地化映射——所有其他$quarto.language.<key>$的解析静默返回空。只读取$quarto.language.crossref-ch-prefix$的模板仍断言 "Bouquin",因此回归测试通过了。
修复方式是让模板至少探测一个未被覆盖的兄弟键。具体地,回归守卫 tests/docs/smoke-all/markdown/lang-fr-user-override-deep-merge.qmd 使用模板
$quarto.language.crossref-ch-prefix$|$quarto.language.toc-title-document$并断言完整字符串^Bouquin\|Table des matières\s*$。修复前输出是Bouquin|;修复后是Bouquin|Table des matières。
启发式规则:为任意两个结构化配置树的合并编写优先级 smoke 测试时,确保断言至少覆盖一条用户没有覆盖的路径。否则测试只证明"被覆盖的值生效了"——而没有证明"其余部分存活"。
- 开发工具
- 文档
【免费下载链接】quarto-cli
Open-source scientific and technical publishing system built on Pandoc.
相关推荐
mockery 的 include-auto-generated 配置:让 mock 生成器正确处理自动生成源码
mockery 的 include auto generated 配置:让 mock 生成器正确处理自动生成源码 导读 Go 生态中存在大量由代码生成器产出的源
开发工具文档Quarto CLI 的 TypeScript 测试体系:Smoke 与 Unit 测试编写实战指南
Quarto CLI 的 TypeScript 测试体系:Smoke 与 Unit 测试编写实战指南 Quarto 是一个基于 Pandoc 的开源科学出版系统
开发工具文档抖音批量下载工具教程:从单个视频到整站存档的完整上手指南
抖音批量下载工具教程:从单个视频到整站存档的完整上手指南 douyin downloader 是一个开源免费的抖音批量下载工具,基于 Python 编写,核心能
CLI开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考