news 2026/10/10 2:35:02

Quarto CLI 测试模式全解析:从 testQuartoCmd 到 smoke 测试的最佳实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Quarto CLI 测试模式全解析:从 testQuartoCmd 到 smoke 测试的最佳实践
  • 开发工具
  • 文档

【免费下载链接】quarto-cli

Open-source scientific and technical publishing system built on Pandoc.

项目地址:https://gitcode.com/gh_mirrors/qu/quarto-cli
点击查看免费下载

本篇指南以 Quarto CLI 仓库中的测试基础设施为核心,系统讲解如何为这一基于 Deno 构建的科学出版工具编写可靠的 smoke 测试。你将掌握testQuartoCmd/testRender两种入口的使用边界、验证器与路径辅助函数的完整语义、Dev 模式与 Binary 模式的行为差异、引擎环境与跨平台陷阱,以及 YAML 内嵌测试的转义规则——全部知识点均有仓库源码与真实测试文件作为依据。

测试架构总览:四个核心文件

Quarto CLI 的测试体系建立在四个相互协作的文件之上(tests/):

文件职责
tests/test.ts核心测试运行器,导出testQuartoCmd、unitTest、TestContext等基础设施
tests/quarto-cmd.tsQuarto 调用分发,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作为项目根而不是当前目录。

最佳实践清单

  1. 始终清理:用 teardown 删除生成的文件。
  2. 使用辅助函数:用docs()、fileExists()等,而不是手写检查。
  3. 绝对路径:所有路径构造用join()处理平台差异。
  4. 测试隔离:创建文件的测试使用临时目录。
  5. 清晰命名:使用描述性变量名如projectDir、outputDir、templateFolder。
  6. 注释意图:添加注释说明应该/不应该发生什么。
  7. 处理错误:用 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.

项目地址:https://gitcode.com/gh_mirrors/qu/quarto-cli
点击查看免费下载
上一篇:一文搞外文视频的中文字幕:PotPlayer 字幕翻译免费配置完整指南
下一篇:League Director 快速上手指南:把 LOL 回放剪成电影级运镜视频,只需这 6 步

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 2:35:01

免SDK绿色版Windows Mobile模拟器搭建:镜像配置与部署实战

简介&#xff1a;绿色版 Windows Mobile 模拟器是一款无需安装即可在电脑上模拟 Windows Mobile 系统的免注册工具&#xff0c;面向应用开发者、测试人员及早期移动系统爱好者&#xff0c;可用于体验和调试 WM 应用程序&#xff0c;无需真实设备即可快速验证功能与界面。整个资…

作者头像 李华
网站建设 2026/10/10 2:34:29

基于Java Servlet的人才公寓客房预订系统开发全攻略

“基于Java Servlet的人才公寓客房预订系统”这种题目&#xff0c;在高校课设和毕业设计里出现的频率非常高&#xff0c;很多同学第一眼看到会觉得是一个老掉牙的“增删改查”项目。但从我实际带过多个类似模拟项目的经验来看&#xff0c;这类系统恰恰是最能检验Java Web基本功…

作者头像 李华
网站建设 2026/10/10 2:32:12

Linux命令行效率手册:文件管理、文本处理与自动化实战

1. 为什么我还在用命令行&#xff1a;图形界面永远替代不了的那些事先说个挺现实的问题&#xff1a;现在随便一个Linux发行版&#xff0c;默认桌面环境都做得相当漂亮&#xff0c;文件管理器拖拽、右键菜单、图形化设置中心&#xff0c;看起来完全够用。那为什么我还要花力气折…

作者头像 李华