pup 测试体系深度解析:用 sha1 快照守护命令行 HTML 解析器的行为一致性
发布时间:2026/10/14 6:10:05 锦皓数字建站

开发工具【免费下载链接】pupParsing HTML at the command line项目地址https://gitcode.com/gh_mirrors/pu/pup点击查看免费下载tests/README.md描述了 pup一个用 CSS 选择器在命令行解析 HTML 的 Go 工具的回归测试体系它不做功能正确性断言而是通过命令 → 输出 sha1 摘要的黄金快照确保每次代码改动后 pup 的行为完全一致。本文以该文档为骨架结合 tests/ 目录下的真实测试文件与 pup.go、selector.go、parse.go 等源码实现完整还原这套快照测试的运行机制、文件格式与维护方法并剖析测试背后的命令执行链路与覆盖范围。读完你将能独立运行这套测试、读懂每一行 sha1 的含义并学会如何把它迁移到其他 CLI 工具的回归保障中。测试定位不是功能测试而是行为一致性回归tests/README.md开篇就给出了一个容易被忽略却非常关键的定位说明A simple set of tests to help maintain sanity. These tests dont actually test functionality they only make sure pup behaves the same after code changes.即这套测试不验证选择器选得对不对那是 parse_test.go 之类的单元测试职责只验证代码改了之后pup 的输出和以前一模一样。这是典型的 golden file / snapshot 回归测试思路固定输入index.html测试夹具 固定命令清单cmds.txt⇒ 固定输出对输出做 sha1 摘要并固化为基线expected_output.txt每次代码变更后重跑任何摘要变化都意味着行为发生了漂移需要人工确认是预期变更还是意外回归。这种测试的独特价值在于它不关心解析结果是否符合 HTML 规范只关心行为稳定性。当你在重构选择器解析、显示函数或 HTML 序列化逻辑时它能以极低成本捕获到任何肉眼难察觉的输出变化。测试套件全景五个文件一条流水线整个测试体系由 tests/ 目录下的五个文件构成职责清晰文件类型职责index.html测试夹具固定输入页面共 1494 行是维基百科 Go (programming language) 页面的完整 HTML 快照cmds.txt命令清单49 条待执行命令逐行喂给pupexpected_output.txt黄金基线每条命令的输出 sha1 摘要格式为sha1sum commandrun.py驱动器用 Python 子进程逐条执行命令并计算 sha1test运行脚本一个纯 bash 脚本重新生成结果并与基线 diff流水线关系如下index.html ──► run.py逐行读取 cmds.txt执行 pup──► sha1 摘要 │ test 脚本run.py test_results.txt │ ▼ diff expected_output.txt test_results.txt测试夹具index.html一张真实世界的 DOM 考卷cmds.txt里的所有命令都作用在 index.html 上。它并非手工构造的最小样例而是一份完整的维基百科页面 HTML标题为Go (programming language) - Wikipedia包含完整的head大量link、meta、style、script节点复杂的正文结构#toc目录树多级嵌套ul/li见 index.html、#footer页脚三组ul与多级li见 index.htmlclasssummary的caption、.navbox-list导航表格、The Practice of Programming链接index.html等具体可检索目标大量id、class、title、lang、href、rel属性为属性选择器提供充分素材。选择真实页面作为夹具的收益是选择器在真实 DOM 上的匹配行为如:first-child与:first-of-type的差异、相邻兄弟的语义会被固化下来。这与单元测试中使用最小构造 DOM 互为补充——真实页面更能暴露我以为的 DOM与实际 HTML 解析器解析出的 DOM之间的差异。命令清单cmds.txt 的完整分类cmds.txt 共 49 行每行一条命令覆盖了选择器、组合器、伪类、显示函数与标志位五大类。下面按类别完整罗列这正是基线中每一条 sha1 的输入来源1. 基本选择器标签、id、class、属性#footer #footer li .after-portlet .after .summary [classsummary] [classsummary] #toc table li link, a # 以及 link ,a、link , a 两种空格变体 table a[titleThe Practice of Programming]注意[classsummary]与[classsummary]、.summary三条命令在 expected_output.txt 中摘要完全相同均为64103781...说明属性值的引号写法与类选择器在解析语义上等价这一行为被基线固化了。2. 组合器、、,#footer li a #footer li li #toc li a #toc li a span #toc li span #toc li li link , a sup从基线可以看到一个有趣的细节#footer li a、#footer li a attr{title}、#footer li li的输出摘要都是da39a3ee5e6b4b0d3255bfef95601890afd80709——这是sha1 空字符串的著名值即这些组合器在当前夹具上匹配不到任何节点。这说明#footer下的a都嵌套在li内部而非其兄弟节点li的下一兄弟也是li而非li的子节点。空输出也要有摘要正是快照测试的严谨之处它防止的是未来某次改动让原本为空的结果突然有了输出这类回归。3. 伪类Pseudo Classestable li:first-child table li:first-of-type table li:last-child table li:last-of-type :empty td:empty .navbox-list li:nth-child(1) .navbox-list li:nth-child(2) .navbox-list li:nth-child(3) .navbox-list li:nth-last-child(1) .navbox-list li:nth-last-child(2) .navbox-list li:nth-last-child(3) .navbox-list li:nth-child(n1) .navbox-list li:nth-child(3n1) .navbox-list li:nth-last-child(n1) .navbox-list li:nth-last-child(3n1) :only-child .navbox-list li:only-child li a:not([rel]) link , a:parent-of(sup)基线上呈现的等价性证据均为同一摘要table li:first-child与table li:first-of-type相同505c04a4...last-child与last-of-type相同66950e74...——在该夹具中两类伪类恰好命中同一批节点.navbox-list li:nth-child(n1)与nth-last-child(n1)相同52e726f0...因为n1从两个方向计数都覆盖全部元素link , a:parent-of(sup) sup与link , a sup相同0d1f6676...验证了:parent-of与直接后代选择在命中集合上的一致。4. 显示函数与标志位#footer li a attr{title} table a[titleThe Practice of Programming] text{} #toc li a text{} #toc li a json{} li --number li -nli --number与li -n直接出现在命令清单里说明作者意图覆盖--number/-n标志见下文源码视角的深入分析。黄金基线expected_output.txt 的格式与语义expected_output.txt 共 49 行与cmds.txt一一对应每行格式为40位sha1十六进制摘要 原始命令例如c00fef10d36c1166cb5ac886f9d25201b720e37e #footer a7bb8dbfdd638bacad0aa9dc3674126d396b74e2 #footer li da39a3ee5e6b4b0d3255bfef95601890afd80709 #footer li a 0d3918d54f868f13110262ffbb88cbb0b083057d table a[titleThe Practice of Programming] text{} ecb542a30fc75c71a0c6380692cbbc4266ccbce4 json{}解读要点空输出哨兵da39a3ee5e6b4b0d3255bfef95601890afd80709是 SHA1 对空字符串的摘要在基线上出现了约 9 次#footer li a、.after、#toc li a、#toc li a text{}、#toc li li、li --number、li -n等。它代表命令合法执行、但未选中任何节点、也未产生输出同一选择器不同显示函数#toc li a与#toc li a text{}都是空摘要但#toc li a json{}是97d170e1...。结合 display.go 的实现可知json{}走JSONDisplayer对空节点列表也会输出[]空数组因此摘要必然非空——显示函数决定了空结果的呈现形态摘要即行为指纹任何节点输出顺序、缩进、转义、属性序列化格式的变化都会导致摘要改变。这正是回归测试要捕获的信号。测试驱动器run.py 的工作原理run.py 是整套测试的核心执行器全文仅 14 行逻辑却非常紧凑#!/usr/bin/env python from __future__ import print_function from hashlib import sha1 from subprocess import Popen, PIPE, STDOUT data open(index.html, r).read() for line in open(cmds.txt, r): line line.strip() p Popen([pup, line], stdoutPIPE, stdinPIPE, stderrPIPE) h sha1() h.update(p.communicate(inputdata)[0]) print(%s %s % (h.hexdigest(), line))逐段拆解读取整个 index.html 作为统一输入流data逐行读取cmds.txt并strip()掉首尾空白对每一行启动子进程pup把整行命令作为一个 argv 参数传入[pup, line]同时把data通过 stdin 管道喂给 pup用hashlib.sha1对 pup 的 stdout 计算摘要打印摘要 命令顺序与cmds.txt一致。这里有两个值得注意的实现事实命令以单个参数传递Popen([pup, line])中line是完整的一行字符串。pup 内部会自行按空格、逗号和引号做二次切分见下文ParseCommands所以#footer li a这类含空格的表达式能被正确解析为多个 token输出指纹化测试不关心输出内容本身只关心sha1摘要。这样做的好处是基线文件体积极小49 行文本且 diff 时能一眼看出哪条命令的行为变了。一键运行test 脚本tests/test 是一个纯 bash 脚本只有两行#!/bin/bash python run.py test_results.txt diff expected_output.txt test_results.txt执行流程运行python run.py将 49 条命令的实时摘要写入test_results.txt用diff对比基线与实时结果若无差异diff 静默退出退出码 0测试通过若有差异diff 会精确列出每一行变化的新旧摘要及对应命令。运行前需要确保两点当前目录是 tests/且pup可执行文件在PATH中例如在仓库根目录执行go build -o pup .后将pup加入 PATH。维护工作流验证与重新生成基线tests/README.md给出了两种使用模式模式一日常验证不应有任何输出$ cd tests ./test正常情况 diff 无差异、无输出。若代码发生了行为变更你会看到类似 97d170e1550eee4afc0af065b78cda302a97674c #toc li a json{} --- 新摘要 #toc li a json{}左侧是基线中的旧摘要右侧是当前代码产生的新摘要。这代表行为已漂移需要人工判断是预期改进还是意外回归——这正是 README 所说的 youll see the sha1sums that changed and the commands that produced that change。模式二确认变更是预期的重新生成基线$ cd tests python run.py expected_output.txtREADME 原文为To overwrite the current sha1sums, just run python run.py expected_output.txt。这条命令把当前行为固化为新基线之后./test又会恢复静默。注意它的语义基线覆盖只应在人工确认新行为正确、且输出符合预期时执行否则会掩盖真正的回归。源码视角这些命令在 pup 内部如何被执行快照测试的另一个价值是cmds.txt里的每一条命令都对应 pup.go 中一段真实的执行链路。主流程在 pup.gocmds, err : ParseArgs() // 处理标志位 切分命令 root, err : ParseHTML(pupIn, pupCharset) // 解析 HTML 得到根节点 // 遍历 cmds逐个构造 selectorFuncs selectedNodes : []*html.Node{} currNodes : []*html.Node{root} for _, selectorFunc : range selectorFuncs { if selectorFunc nil { // 遇到逗号合并已选节点重置为根 selectedNodes append(selectedNodes, currNodes...) currNodes []*html.Node{root} } else { currNodes selectorFunc(currNodes) // 前一选择器的结果作为下一选择器输入 } } selectedNodes append(selectedNodes, currNodes...) pupDisplayer.Display(selectedNodes)命令切分与引号处理ParseArgs会先调用ParseCommandsparse.go把整行命令按空格、逗号和引号切分为 token。它感知引号与转义:contains(History)里的引号内容不会被错误切分未闭合的引号会直接报错Unmatched open quote。这条逻辑在 parse_test.go 中有数十条单元测试覆盖见后文。组合器的真实语义在 pup.go 中case : funcGenerator SelectFromChildren // 直接子节点 case : funcGenerator SelectNextSibling // 相邻下一个兄弟元素 case ,: selectorFuncs append(selectorFuncs, nil) // 逗号作为分组标记对应 selector.go 中的两个实现SelectFromChildrenselector.go遍历当前节点的直接子节点仅匹配一层SelectNextSiblingselector.go从当前节点的NextSibling开始找第一个元素节点并判断是否匹配。#footer li li之所以为空输出正是因为#footer下li的直接子节点中没有li——子选择器只查一层这正是 CSS 子选择器的语义。四种显示函数display.go 的ParseDisplayer负责识别最后一个参数是否为显示函数text{}→TextDisplayerdisplay.go深度优先打印所有文本节点attr{key}→AttrDisplayerdisplay.go打印所有匹配属性键的值正则限定键名为[a-zA-Z\-]json{}→JSONDisplayerdisplay.go把节点序列化为{tag:..., attrs:..., text:..., children:[...]}结构用json.MarshalIndent输出默认缩进为单个空格默认 →TreeDisplayer补齐缺失标签、缩进输出 HTML 树。li --number/li -n的细节--number/-n在 parse.go 中会设置pupDisplayer NumDisplayer{}其输出是选中节点数量display.go。但基线上这两条命令的输出摘要都是da39a3ee...空输出结合run.py以单个 argv 元素传入整行命令的实现可以推断li --number在 pup 内部被ParseCommands切成li与--number两个 token 后--number进入了选择器解析分支pup.go被当作标签名--number匹配——任何 HTML 元素都不会叫这个名字于是选不出任何节点NumDisplayer也未被设置。这说明快照测试既固化了正常路径也固化了这种标志位出现在表达式中间的边界行为防止未来改动悄悄改变它的结果。从基线反推覆盖度测试到了什么、没测到什么把cmds.txt与 parse.go 的帮助文本pup --help的输出源对照可以看清覆盖矩阵能力基线覆盖情况标签 / id / class 选择器✅ 覆盖#footer、.summary、table li等属性选择器含、引号变体✅ 覆盖组合器、、,含空格变体✅ 覆盖伪类child/of-type/empty/nth/not/parent-of/only✅ 覆盖显示函数text{}、attr{}、json{}✅ 覆盖标志位-n/--number✅ 覆盖但如上文分析实际以选择器路径执行标志位-c/--color、-i/--indent、-l/--limit、-p/--plain、--pre、--charset、-f/--file❌ 未在cmds.txt中覆盖这解释了run.py --help风格补充测试的价值--color的 ANSI 转义输出、--indent 4的 JSON 缩进、--limit的截断输出、--plain的 HTML 转义开关pupEscapeHTML见 parse.go目前只由 README 示例文档化尚未进入快照基线。与单元测试互补parse_test.go 的另一道防线快照测试验证整体行为不变而 parse_test.go 验证命令切分逻辑正确两者互补。TestParseCommandsparse_test.go用数十个用例覆盖ParseCommands的边界parseCmdTest{w1 w2 w3, []string{w1, w2 w3}, true}, // 引号内空格不切分 parseCmdTest{w1,w2 w3, []string{w1, ,, w2 w3}, true}, // 逗号独立成 token parseCmdTest{h1 , .article-teaser , .article-content, ...}, // 逗号与空格混排 parseCmdTest{w1 w2 w3, []string{}, false}, // 未闭合引号报错这些用例把引号、转义、逗号分隔的解析规则精确固化与快照测试整体行为稳定的粒度形成互补——一个是白盒单元级一个是黑盒系统级。小结一套可复用的轻量回归方案pup 的测试体系用不到 20 行脚本就完成了真实页面 命令清单 sha1 摘要 diff 基线的完整回归闭环其方法论完全可以迁移到其他 CLI 工具固定一个真实世界级别的输入样本index.html比手工构造样例更能暴露解析差异把用户可感知的操作固化为命令清单cmds.txt包括边界与看似不合理的用法用摘要而非完整输出做基线expected_output.txt让 diff 聚焦于哪条命令变了提供一键验证./test与一键重基线python run.py expected_output.txt两条路径让保持清醒maintain sanity成为日常开发的一部分。在快速迭代的 CLI 项目中这种不求正确、但求不变的快照测试往往是成本最低、见效最快的行为护栏。赞分享开发工具【免费下载链接】pupParsing HTML at the command line项目地址https://gitcode.com/gh_mirrors/pu/pup点击查看免费下载相关推荐Qwen3.5-35B-A3B架构深度剖析GDNMoE混合架构的技术优势 Qwen3.5 35B A3B架构深度剖析GDNMoE混合架构的技术优势 Qwen3.5 35B A3B 是阿里巴巴通义千问团队推出的革命性混合架构大Microsoft激活脚本Windows和Office免费激活完整指南Microsoft激活脚本Windows和Office免费激活完整指南 Microsoft激活脚本MAS是一个功能强大的开源工具专为Windows和Of操作系统OpenSquilla 流解码 Golden 测试体系用字节级冻结守护 Provider 适配器行为一致性OpenSquilla 流解码 Golden 测试体系用字节级冻结守护 Provider 适配器行为一致性 OpenSquilla 通过统一的 StreamE人工智能大模型AI Agent交互助手工具调用MCP 服务Agent 记忆RAG本地部署上一篇Snyk CLI监控功能深度解析10个技巧持续保护你的项目安全下一篇如何在FreeBSD系统上快速部署Stalwart Mail Server完整Ports安装与配置指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。