feat: 增强 expect 规则系统,支持多种 body 校验方法和操作符
- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath - 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt - 新增 headers 响应头校验 - 引入 cheerio、xpath、@xmldom/xmldom 依赖 - BREAKING: expect.bodyContains 迁移至 expect.body.contains
This commit is contained in:
44
README.md
44
README.md
@@ -73,6 +73,27 @@ targets:
|
||||
expect:
|
||||
status: [200]
|
||||
maxLatencyMs: 5000
|
||||
|
||||
- name: "JSON API 监控"
|
||||
url: "https://httpbin.org/json"
|
||||
expect:
|
||||
status: [200]
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
body:
|
||||
contains: "slideshow"
|
||||
json:
|
||||
$.slideshow.title: "Sample Slide Show"
|
||||
|
||||
- name: "HTML 页面监控"
|
||||
url: "https://httpbin.org/html"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
css:
|
||||
"h1": "Herman Melville - Moby-Dick"
|
||||
xpath:
|
||||
"/html/body/h1/text()": "Herman Melville - Moby-Dick"
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
@@ -93,20 +114,27 @@ targets:
|
||||
- `interval`、`timeout`: 覆盖全局默认值
|
||||
- `expect`: 期望校验
|
||||
- `status`: 可接受的状态码列表
|
||||
- `bodyContains`: 响应体包含的文本
|
||||
- `headers`: 响应头校验(键值对,全部匹配)
|
||||
- `maxLatencyMs`: 最大延迟阈值(毫秒)
|
||||
- `body`: 响应体校验(可组合使用)
|
||||
- `contains`: 响应体包含的文本
|
||||
- `regex`: 响应体匹配的正则表达式
|
||||
- `json`: JSONPath 提取值比较(路径 → 期望值)
|
||||
- `css`: CSS 选择器提取 HTML 元素比较(选择器 → 期望值,可选 `attr` 提取属性)
|
||||
- `xpath`: XPath 提取 XML/HTML 节点比较(路径 → 期望值)
|
||||
- body 比较支持操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||
|
||||
时长格式支持:`30s`、`5m`、`500ms`
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/summary` | 总览统计(total/up/down/avgLatencyMs/lastCheckTime) |
|
||||
| `GET /api/targets` | 目标列表及最新状态和统计摘要 |
|
||||
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
|
||||
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 |
|
||||
| 端点 | 说明 |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/summary` | 总览统计(total/up/down/avgLatencyMs/lastCheckTime) |
|
||||
| `GET /api/targets` | 目标列表及最新状态和统计摘要 |
|
||||
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
|
||||
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 |
|
||||
|
||||
## 代码质量
|
||||
|
||||
|
||||
53
bun.lock
53
bun.lock
@@ -5,9 +5,12 @@
|
||||
"": {
|
||||
"name": "gateway-checker",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1",
|
||||
"xpath": "^0.0.34",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -200,6 +203,8 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -210,6 +215,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
@@ -218,12 +225,20 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
|
||||
"cheerio": ["cheerio@1.2.0", "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
@@ -256,8 +271,20 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.353", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -312,6 +339,10 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
@@ -380,12 +411,20 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.38", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -420,6 +459,8 @@
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.18", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", { "dependencies": { "@oxc-project/types": "=0.128.0", "@rolldown/pluginutils": "1.0.0-rc.18" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", "@rolldown/binding-darwin-x64": "1.0.0-rc.18", "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -444,6 +485,8 @@
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.59.2", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "https://registry.npmmirror.com/undici/-/undici-7.25.0.tgz", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
@@ -456,10 +499,16 @@
|
||||
|
||||
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "https://registry.npmmirror.com/xpath/-/xpath-0.0.34.tgz", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -476,6 +525,10 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,6 @@ rules:
|
||||
- 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线
|
||||
tasks:
|
||||
- 一行一个任务,严禁任务内容跨行
|
||||
- 如果是代码存在更新必须
|
||||
- 执行完整的测试、代码检查、格式检查等质量保障手段
|
||||
- 更新README文档
|
||||
|
||||
117
openspec/specs/expect-body-checkers/spec.md
Normal file
117
openspec/specs/expect-body-checkers/spec.md
Normal file
@@ -0,0 +1,117 @@
|
||||
## Purpose
|
||||
|
||||
定义 HTTP 拨测中响应体校验方法集(contains/regex/json/css/xpath)、操作符系统和响应头校验的行为规范。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 响应体多种校验方法
|
||||
系统 SHALL 支持对 HTTP 响应体进行五种可组合的校验方法:contains(子串)、regex(正则)、json(JSONPath)、css(CSS 选择器)、xpath(XPath),配置在 `expect.body` 分组下。
|
||||
|
||||
#### Scenario: contains 子串匹配
|
||||
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体包含 `"healthy"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: contains 不匹配
|
||||
- **WHEN** 目标配置 `expect.body.contains: "healthy"`,且响应体不包含该文本
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: regex 正则匹配
|
||||
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: regex 不匹配
|
||||
- **WHEN** 目标配置 `expect.body.regex: '"status"\\s*:\\s*"ok"'`,且响应体不匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: json JSONPath 等值匹配
|
||||
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: json JSONPath 值不匹配
|
||||
- **WHEN** 目标配置 `expect.body.json: {"$.status": "ok"}`,且响应 JSON 中 `$.status` 值为 `"error"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: json 解析失败
|
||||
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: css 选择器匹配
|
||||
- **WHEN** 目标配置 `expect.body.css: {"div#health": "OK"}`,且 HTML 中存在 `div#health` 元素文本为 `"OK"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: css 选择器匹配属性值
|
||||
- **WHEN** 目标配置 css 规则带 `attr: "content"` 用于提取属性,且属性值匹配期望
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: css 选择器无匹配元素
|
||||
- **WHEN** 目标配置了 css 选择器但 HTML 中无匹配元素
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: xpath 表达式匹配
|
||||
- **WHEN** 目标配置 `expect.body.xpath: {"/root/status/text()": "ok"}`,且 XML 中 `/root/status` 节点文本为 `"ok"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: xpath 表达式无匹配节点
|
||||
- **WHEN** 目标配置了 xpath 表达式但 XML 中无匹配节点
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 多种 body 校验方法 AND 组合
|
||||
系统 SHALL 支持同时配置多种 body 校验方法,所有方法均通过时 matched 方为 true。
|
||||
|
||||
#### Scenario: 多种方法全部通过
|
||||
- **WHEN** 目标同时配置 `body.contains`、`body.json`、`body.regex`,且全部通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 多种方法任一失败
|
||||
- **WHEN** 目标同时配置 `body.contains` 和 `body.json`,且 `body.contains` 不通过
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false,且不再检查 `body.json`
|
||||
|
||||
### Requirement: 操作符系统
|
||||
系统 SHALL 支持对 body 校验的提取值使用以下操作符进行比较:equals(默认等值)、contains(子串包含)、match(正则匹配)、empty(空值判断)、exists(存在性判断)、gte/lte/gt/lt(数值比较)。
|
||||
|
||||
#### Scenario: 标量值隐式 equals
|
||||
- **WHEN** jsonPath 配置的期望值为标量(字符串/数字/布尔/null),如 `$.status: ok`
|
||||
- **THEN** 系统 SHALL 使用 equals 操作符,对提取值做严格相等比较
|
||||
|
||||
#### Scenario: 显式 contains 操作符
|
||||
- **WHEN** 配置 `$.message: {contains: "success"}`,且提取值包含 `"success"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 显式 match 操作符
|
||||
- **WHEN** 配置 `$.version: {match: '\\d+\\.\\d+\\.\\d+'}`,且提取值匹配该正则
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: empty 操作符判断为空
|
||||
- **WHEN** 配置 `$.items: {empty: true}`,且提取值为空数组 `[]`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: empty 操作符判断非空
|
||||
- **WHEN** 配置 `$.items: {empty: false}`,且提取值为 `[1, 2]`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: exists 操作符判断存在
|
||||
- **WHEN** 配置 `$.error: {exists: false}`,且 JSON 中不存在 `error` 字段
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: gte 数值比较
|
||||
- **WHEN** 配置 `$.count: {gte: 10}`,且提取值为 `15`(数字)
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: gt/lt 数值比较
|
||||
- **WHEN** 配置 `$.latency: {gt: 0, lt: 1000}`,且提取值为 `500`
|
||||
- **THEN** 系统 SHALL 对同一字段进行多操作符复合比较,全部通过则 matched 为 true
|
||||
|
||||
### Requirement: 响应头校验
|
||||
系统 SHALL 支持通过 `expect.headers` 配置对响应头进行键值对校验。
|
||||
|
||||
#### Scenario: 响应头匹配
|
||||
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应包含该 header 且值匹配
|
||||
- **THEN** 系统 SHALL 判定 matched 为 true
|
||||
|
||||
#### Scenario: 响应头不匹配
|
||||
- **WHEN** 目标配置 `expect.headers: {"Content-Type": "application/json"}`,且响应 header 值为 `"text/html"`
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
#### Scenario: 响应头缺失
|
||||
- **WHEN** 目标配置了某个 header 但响应中不存在该 header
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
@@ -55,3 +55,23 @@
|
||||
#### Scenario: 解析 YAML 内容
|
||||
- **WHEN** 系统读取 YAML 文件内容
|
||||
- **THEN** 系统 SHALL 调用 `Bun.YAML.parse()` 将内容解析为配置对象
|
||||
|
||||
### Requirement: expect 配置增强
|
||||
系统 SHALL 支持增强的 expect 配置格式,包括 `headers` 响应头校验和 `body` 分组下的多种校验方法(contains、regex、json、css、xpath)。
|
||||
|
||||
#### Scenario: 解析增强的 expect 配置
|
||||
- **WHEN** YAML 配置文件中 target 的 expect 包含 headers、body 分组及内部方法
|
||||
- **THEN** 系统 SHALL 正确解析并存储为 ResolvedTarget 的 expect 字段
|
||||
|
||||
#### Scenario: 解析仅含 body.contains 的最简配置
|
||||
- **WHEN** YAML 中 target 配置 `expect.body.contains: "healthy"`
|
||||
- **THEN** 系统 SHALL 正确解析,功能等价于旧版 `expect.bodyContains`
|
||||
|
||||
#### Scenario: 不配置 expect
|
||||
- **WHEN** target 未配置任何 expect 规则
|
||||
- **THEN** 系统 SHALL 正常处理,expect 字段为 undefined
|
||||
|
||||
#### Scenario: 旧版 bodyContains 字段不再支持
|
||||
- **WHEN** YAML 中使用 `expect.bodyContains: "xxx"` 格式
|
||||
- **THEN** 该字段 SHALL 被忽略(系统仅识别 `expect.body.contains`)
|
||||
- **Migration**: 将配置文件中 `expect.bodyContains: "xxx"` 改为 `expect.body.contains: "xxx"`
|
||||
|
||||
@@ -59,10 +59,30 @@
|
||||
- **WHEN** 目标配置了 `expect.status: [200, 201]`
|
||||
- **THEN** 系统 SHALL 检查响应状态码是否在列表中,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应头
|
||||
- **WHEN** 目标配置了 `expect.headers: {"Content-Type": "application/json"}`
|
||||
- **THEN** 系统 SHALL 检查响应头是否包含指定键值对,全部匹配时将 matched 设为 true
|
||||
|
||||
#### Scenario: 校验响应体包含
|
||||
- **WHEN** 目标配置了 `expect.bodyContains: "healthy"`
|
||||
- **WHEN** 目标配置了 `expect.body.contains: "healthy"`
|
||||
- **THEN** 系统 SHALL 检查响应体是否包含该文本,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验响应体正则
|
||||
- **WHEN** 目标配置了 `expect.body.regex: '"status"\\s*:\\s*"ok"'`
|
||||
- **THEN** 系统 SHALL 检查响应体是否匹配该正则,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 JSON 响应
|
||||
- **WHEN** 目标配置了 `expect.body.json: {"$.status": "ok"}`
|
||||
- **THEN** 系统 SHALL 解析 JSON 并检查 JSONPath 对应值是否符合期望,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTML 响应(CSS 选择器)
|
||||
- **WHEN** 目标配置了 `expect.body.css: {"div#health": "OK"}`
|
||||
- **THEN** 系统 SHALL 解析 HTML 并用 CSS 选择器提取元素文本进行比较,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验 HTML/XML 响应(XPath)
|
||||
- **WHEN** 目标配置了 `expect.body.xpath: {"/root/status/text()": "ok"}`
|
||||
- **THEN** 系统 SHALL 解析文档并用 XPath 提取节点文本进行比较,将匹配结果记录到 matched 字段
|
||||
|
||||
#### Scenario: 校验延迟阈值
|
||||
- **WHEN** 目标配置了 `expect.maxLatencyMs: 3000`
|
||||
- **THEN** 系统 SHALL 检查实际延迟是否超过阈值,将匹配结果记录到 matched 字段
|
||||
@@ -72,9 +92,24 @@
|
||||
- **THEN** 系统 SHALL 将 matched 字段设为 true
|
||||
|
||||
#### Scenario: 多条 expect 规则
|
||||
- **WHEN** 目标同时配置了 status、bodyContains 和 maxLatencyMs
|
||||
- **WHEN** 目标同时配置了 status、headers、body.contains、body.json 和 maxLatencyMs
|
||||
- **THEN** 系统 SHALL 所有规则全部通过时 matched 为 true,任一不通过则为 false
|
||||
|
||||
#### Scenario: 多种 body 方法 AND 组合
|
||||
- **WHEN** 目标在 body 分组下配置了 contains、json、css 多种方法
|
||||
- **THEN** 系统 SHALL 按 contains → regex → json → css → xpath 顺序执行,任一失败立即返回 false
|
||||
|
||||
### Requirement: Body 校验按需解析
|
||||
系统 SHALL 仅在配置了对应 body 校验方法时才解析响应体为对应格式,避免不必要的解析开销。
|
||||
|
||||
#### Scenario: 仅配置 contains 时不解析 JSON
|
||||
- **WHEN** 目标仅配置 `expect.body.contains` 而未配置 json/css/xpath
|
||||
- **THEN** 系统 SHALL 不执行 JSON.parse 或 HTML/XML 解析
|
||||
|
||||
#### Scenario: 配置 json 时解析 JSON 失败
|
||||
- **WHEN** 目标配置了 `expect.body.json` 但响应体不是合法 JSON
|
||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||
|
||||
### Requirement: 拨测结果记录
|
||||
系统 SHALL 在每次拨测完成后,将结果写入 SQLite 数据存储,包含 target_id、timestamp、success、status_code、latency_ms、error、matched 字段。
|
||||
|
||||
|
||||
@@ -34,8 +34,11 @@
|
||||
"vite": "^8.0.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"cheerio": "^1.2.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"recharts": "^3.8.1"
|
||||
"recharts": "^3.8.1",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,23 @@ targets:
|
||||
expect:
|
||||
status: [200]
|
||||
maxLatencyMs: 10000
|
||||
|
||||
- name: "JSON API 示例"
|
||||
url: "https://httpbin.org/json"
|
||||
expect:
|
||||
status: [200]
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
body:
|
||||
contains: "slideshow"
|
||||
json:
|
||||
$.slideshow.title: "Sample Slide Show"
|
||||
|
||||
- name: "HTML 页面示例"
|
||||
url: "https://httpbin.org/html"
|
||||
expect:
|
||||
status: [200]
|
||||
body:
|
||||
contains: "Moby-Dick"
|
||||
xpath:
|
||||
"/html/body/h1/text()": "Herman Melville - Moby-Dick"
|
||||
|
||||
@@ -43,7 +43,7 @@ try {
|
||||
|
||||
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
|
||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
||||
assertSecurityHeaders((await fetch(`${baseUrl}/api/summary`)), "/api/summary");
|
||||
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
|
||||
|
||||
const { body: targets } = await expectJson(`${baseUrl}/api/targets`, 200);
|
||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
||||
|
||||
@@ -84,13 +84,7 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
|
||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
function handleHistory(
|
||||
idStr: string,
|
||||
url: URL,
|
||||
method: string,
|
||||
store: ProbeStore,
|
||||
mode: RuntimeMode,
|
||||
): Response {
|
||||
function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
@@ -118,13 +112,7 @@ function handleHistory(
|
||||
return jsonResponse(results, { method, mode });
|
||||
}
|
||||
|
||||
function handleTrend(
|
||||
idStr: string,
|
||||
url: URL,
|
||||
method: string,
|
||||
store: ProbeStore,
|
||||
mode: RuntimeMode,
|
||||
): Response {
|
||||
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
const id = Number(idStr);
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
|
||||
188
src/server/checker/body-expect.ts
Normal file
188
src/server/checker/body-expect.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { BodyExpectConfig, CssExpect, ExpectOperator, ExpectValue } from "./types";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (actual !== expected) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
actual === null ||
|
||||
actual === undefined ||
|
||||
actual === "" ||
|
||||
(Array.isArray(actual) && actual.length === 0) ||
|
||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
function checkBodyContains(body: string, contains: string): boolean {
|
||||
return body.includes(contains);
|
||||
}
|
||||
|
||||
function checkBodyRegex(body: string, regex: string): boolean {
|
||||
return new RegExp(regex).test(body);
|
||||
}
|
||||
|
||||
function checkBodyJson(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkBodyCss(body: string, rules: Record<string, CssExpect>): boolean {
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [selector, expected] of Object.entries(rules)) {
|
||||
if (!checkCssRule($, selector, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCssRule($: cheerio.CheerioAPI, selector: string, expected: CssExpect): boolean {
|
||||
if (!isObject(expected)) {
|
||||
const el = $(selector);
|
||||
return el.length > 0 && el.text() === String(expected);
|
||||
}
|
||||
|
||||
const rule = expected as ExpectOperator & { attr?: string };
|
||||
const { attr, ...operators } = rule;
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (attr !== undefined) {
|
||||
return $(selector).attr(attr) !== undefined;
|
||||
}
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
return $(selector).length === 0;
|
||||
}
|
||||
|
||||
const el = $(selector);
|
||||
if (el.length === 0) return false;
|
||||
|
||||
const actual = attr ? el.attr(attr) : el.text();
|
||||
return applyOperator(actual ?? "", operators);
|
||||
}
|
||||
|
||||
function checkBodyXpath(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nodes = xpath.select(path, doc as any);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return false;
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, config?: BodyExpectConfig): boolean {
|
||||
if (!config) return true;
|
||||
|
||||
if (config.contains !== undefined && !checkBodyContains(body, config.contains)) return false;
|
||||
if (config.regex !== undefined && !checkBodyRegex(body, config.regex)) return false;
|
||||
if (config.json !== undefined && !checkBodyJson(body, config.json)) return false;
|
||||
if (config.css !== undefined && !checkBodyCss(body, config.css)) return false;
|
||||
if (config.xpath !== undefined && !checkBodyXpath(body, config.xpath)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
|
||||
import { checkBodyExpect } from "./body-expect";
|
||||
|
||||
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -17,8 +18,9 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const body = await response.text();
|
||||
const responseHeaders = headersToRecord(response.headers);
|
||||
|
||||
const matched = checkExpect(response.status, body, latencyMs, target.expect);
|
||||
const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
@@ -38,7 +40,7 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : (error instanceof Error ? error.message : String(error)),
|
||||
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
||||
matched: false,
|
||||
};
|
||||
} finally {
|
||||
@@ -46,14 +48,37 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
}
|
||||
}
|
||||
|
||||
export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean {
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkExpect(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
latencyMs: number,
|
||||
responseHeaders: Record<string, string>,
|
||||
expect?: ExpectConfig,
|
||||
): boolean {
|
||||
if (!expect) return true;
|
||||
|
||||
if (expect.status && !expect.status.includes(statusCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expect.bodyContains && !body.includes(expect.bodyContains)) {
|
||||
if (expect.headers) {
|
||||
for (const [key, expectedValue] of Object.entries(expect.headers)) {
|
||||
const actualValue = responseHeaders[key.toLowerCase()];
|
||||
if (!actualValue || actualValue !== expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkBodyExpect(body, expect.body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,16 @@ export class ProbeStore {
|
||||
existingMap.get(target.name)!,
|
||||
);
|
||||
} else {
|
||||
insertStmt.run(target.name, target.url, target.method, headers, target.body ?? null, target.intervalMs, target.timeoutMs, expect);
|
||||
insertStmt.run(
|
||||
target.name,
|
||||
target.url,
|
||||
target.method,
|
||||
headers,
|
||||
target.body ?? null,
|
||||
target.intervalMs,
|
||||
target.timeoutMs,
|
||||
expect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +142,9 @@ export class ProbeStore {
|
||||
}
|
||||
|
||||
getLatestCheck(targetId: number): StoredCheckResult | null {
|
||||
return this.db.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1").get(targetId) as
|
||||
| StoredCheckResult
|
||||
| null;
|
||||
return this.db
|
||||
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
|
||||
.get(targetId) as StoredCheckResult | null;
|
||||
}
|
||||
|
||||
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
|
||||
@@ -183,7 +192,10 @@ export class ProbeStore {
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(targetId: number, hours = 24): Array<{
|
||||
getTrend(
|
||||
targetId: number,
|
||||
hours = 24,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgLatencyMs: number | null;
|
||||
availability: number;
|
||||
@@ -257,7 +269,9 @@ export class ProbeStore {
|
||||
|
||||
getSparkline(targetId: number, limit = 20): number[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?")
|
||||
.prepare(
|
||||
"SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{ latency_ms: number }>;
|
||||
return rows.map((r) => r.latency_ms).reverse();
|
||||
}
|
||||
|
||||
@@ -28,10 +28,35 @@ export interface TargetConfig {
|
||||
expect?: ExpectConfig;
|
||||
}
|
||||
|
||||
export interface ExpectOperator {
|
||||
equals?: string | number | boolean | null;
|
||||
contains?: string;
|
||||
match?: string;
|
||||
empty?: boolean;
|
||||
exists?: boolean;
|
||||
gte?: number;
|
||||
lte?: number;
|
||||
gt?: number;
|
||||
lt?: number;
|
||||
}
|
||||
|
||||
export type ExpectValue = string | number | boolean | null | ExpectOperator;
|
||||
|
||||
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string });
|
||||
|
||||
export interface BodyExpectConfig {
|
||||
contains?: string;
|
||||
regex?: string;
|
||||
json?: Record<string, ExpectValue>;
|
||||
css?: Record<string, CssExpect>;
|
||||
xpath?: Record<string, ExpectValue>;
|
||||
}
|
||||
|
||||
export interface ExpectConfig {
|
||||
status?: number[];
|
||||
bodyContains?: string;
|
||||
maxLatencyMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyExpectConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedTarget {
|
||||
|
||||
@@ -16,11 +16,7 @@ export function App() {
|
||||
<p className="dashboard-subtitle">HTTP 拨测监控面板</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
请求失败: {error},将在下一次轮询周期自动重试
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-banner">请求失败: {error},将在下一次轮询周期自动重试</div>}
|
||||
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<TargetTable targets={targets} loading={targetsLoading} />
|
||||
|
||||
@@ -40,9 +40,7 @@ export function TargetDetail({ target }: TargetDetailProps) {
|
||||
<div className="detail-stats">
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">状态</span>
|
||||
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>
|
||||
{isUp ? "UP" : "DOWN"}
|
||||
</span>
|
||||
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>{isUp ? "UP" : "DOWN"}</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">可用率</span>
|
||||
@@ -80,18 +78,10 @@ export function TargetDetail({ target }: TargetDetailProps) {
|
||||
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.success && item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
<span className="history-time">
|
||||
{new Date(item.timestamp).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
{item.statusCode && (
|
||||
<span className="history-code">{item.statusCode}</span>
|
||||
)}
|
||||
{item.latencyMs !== null && (
|
||||
<span className="history-latency">{Math.round(item.latencyMs)}ms</span>
|
||||
)}
|
||||
{item.error && (
|
||||
<span className="history-error">{item.error}</span>
|
||||
)}
|
||||
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
|
||||
{item.statusCode && <span className="history-code">{item.statusCode}</span>}
|
||||
{item.latencyMs !== null && <span className="history-latency">{Math.round(item.latencyMs)}ms</span>}
|
||||
{item.error && <span className="history-error">{item.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,20 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<YAxis yAxisId="latency" tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "ms", position: "insideTopRight", fontSize: 11 }} />
|
||||
<YAxis yAxisId="availability" orientation="right" domain={[0, 100]} tick={{ fontSize: 12 }} stroke="#94a3b8" label={{ value: "%", position: "insideTopLeft", fontSize: 11 }} />
|
||||
<YAxis
|
||||
yAxisId="latency"
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
label={{ value: "ms", position: "insideTopRight", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="availability"
|
||||
orientation="right"
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#94a3b8"
|
||||
label={{ value: "%", position: "insideTopLeft", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: unknown, name: unknown) => {
|
||||
const num = Number(value);
|
||||
@@ -37,8 +49,24 @@ export function TrendChart({ data, loading }: TrendChartProps) {
|
||||
return [String(value), nameStr];
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="latency" type="monotone" dataKey="avgLatencyMs" stroke="#356dd2" strokeWidth={2} dot={false} name="avgLatencyMs" />
|
||||
<Line yAxisId="availability" type="monotone" dataKey="availability" stroke="#1fbf75" strokeWidth={2} dot={false} name="availability" />
|
||||
<Line
|
||||
yAxisId="latency"
|
||||
type="monotone"
|
||||
dataKey="avgLatencyMs"
|
||||
stroke="#356dd2"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="avgLatencyMs"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="availability"
|
||||
type="monotone"
|
||||
dataKey="availability"
|
||||
stroke="#1fbf75"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="availability"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,9 @@ describe("API 路由", () => {
|
||||
|
||||
test("无效 limit 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`));
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
230
tests/server/checker/body-expect.test.ts
Normal file
230
tests/server/checker/body-expect.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyOperator, checkBodyExpect, evaluateJsonPath } from "../../../src/server/checker/body-expect";
|
||||
|
||||
describe("evaluateJsonPath", () => {
|
||||
const obj = {
|
||||
status: "ok",
|
||||
code: 0,
|
||||
active: true,
|
||||
error: null,
|
||||
data: {
|
||||
count: 42,
|
||||
items: [{ name: "a" }, { name: "b" }],
|
||||
nested: { deep: "value" },
|
||||
},
|
||||
emptyObj: {},
|
||||
emptyArr: [],
|
||||
};
|
||||
|
||||
test("简单字段访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.status")).toBe("ok");
|
||||
expect(evaluateJsonPath(obj, "$.code")).toBe(0);
|
||||
expect(evaluateJsonPath(obj, "$.active")).toBe(true);
|
||||
expect(evaluateJsonPath(obj, "$.error")).toBeNull();
|
||||
});
|
||||
|
||||
test("嵌套对象访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.count")).toBe(42);
|
||||
expect(evaluateJsonPath(obj, "$.data.nested.deep")).toBe("value");
|
||||
});
|
||||
|
||||
test("数组索引访问", () => {
|
||||
expect(evaluateJsonPath(obj, "$.data.items[0].name")).toBe("a");
|
||||
expect(evaluateJsonPath(obj, "$.data.items[1].name")).toBe("b");
|
||||
});
|
||||
|
||||
test("路径不存在返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "$.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.notExist")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, "$.data.items[99]")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("空对象和空数组", () => {
|
||||
expect(evaluateJsonPath(obj, "$.emptyObj")).toEqual({});
|
||||
expect(evaluateJsonPath(obj, "$.emptyArr")).toEqual([]);
|
||||
});
|
||||
|
||||
test("非 $ 开头路径返回 undefined", () => {
|
||||
expect(evaluateJsonPath(obj, "status")).toBeUndefined();
|
||||
expect(evaluateJsonPath(obj, ".status")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("null 对象上访问", () => {
|
||||
expect(evaluateJsonPath(null, "$.any")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOperator", () => {
|
||||
test("equals 操作符", () => {
|
||||
expect(applyOperator("ok", { equals: "ok" })).toBe(true);
|
||||
expect(applyOperator("ok", { equals: "error" })).toBe(false);
|
||||
expect(applyOperator(42, { equals: 42 })).toBe(true);
|
||||
expect(applyOperator(42, { equals: 41 })).toBe(false);
|
||||
expect(applyOperator(null, { equals: null })).toBe(true);
|
||||
expect(applyOperator(true, { equals: true })).toBe(true);
|
||||
});
|
||||
|
||||
test("contains 操作符", () => {
|
||||
expect(applyOperator("hello world", { contains: "hello" })).toBe(true);
|
||||
expect(applyOperator("hello world", { contains: "missing" })).toBe(false);
|
||||
expect(applyOperator(12345, { contains: "23" })).toBe(true);
|
||||
});
|
||||
|
||||
test("match 操作符", () => {
|
||||
expect(applyOperator("v2.1.0", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(true);
|
||||
expect(applyOperator("v2.1", { match: "\\d+\\.\\d+\\.\\d+" })).toBe(false);
|
||||
expect(applyOperator("abc123", { match: "^\\w+\\d+$" })).toBe(true);
|
||||
});
|
||||
|
||||
test("empty 操作符", () => {
|
||||
expect(applyOperator("", { empty: true })).toBe(true);
|
||||
expect(applyOperator(null, { empty: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { empty: true })).toBe(true);
|
||||
expect(applyOperator([], { empty: true })).toBe(true);
|
||||
expect(applyOperator({}, { empty: true })).toBe(true);
|
||||
expect(applyOperator("ok", { empty: true })).toBe(false);
|
||||
expect(applyOperator([1, 2], { empty: false })).toBe(true);
|
||||
expect(applyOperator([], { empty: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("exists 操作符", () => {
|
||||
expect(applyOperator("ok", { exists: true })).toBe(true);
|
||||
expect(applyOperator(null, { exists: true })).toBe(true);
|
||||
expect(applyOperator(undefined, { exists: true })).toBe(false);
|
||||
expect(applyOperator(undefined, { exists: false })).toBe(true);
|
||||
expect(applyOperator("ok", { exists: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("gte 操作符", () => {
|
||||
expect(applyOperator(10, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gte: 5 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5 })).toBe(false);
|
||||
expect(applyOperator("10", { gte: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
test("lte 操作符", () => {
|
||||
expect(applyOperator(3, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lte: 5 })).toBe(true);
|
||||
expect(applyOperator(10, { lte: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("gt 操作符", () => {
|
||||
expect(applyOperator(10, { gt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { gt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("lt 操作符", () => {
|
||||
expect(applyOperator(3, { lt: 5 })).toBe(true);
|
||||
expect(applyOperator(5, { lt: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("多操作符 AND 组合", () => {
|
||||
expect(applyOperator(7, { gte: 5, lte: 10 })).toBe(true);
|
||||
expect(applyOperator(3, { gte: 5, lte: 10 })).toBe(false);
|
||||
expect(applyOperator(15, { gte: 5, lte: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkBodyExpect", () => {
|
||||
test("无 body config 返回 true", () => {
|
||||
expect(checkBodyExpect("anything", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("contains 匹配", () => {
|
||||
expect(checkBodyExpect("hello world", { contains: "hello" })).toBe(true);
|
||||
expect(checkBodyExpect("hello world", { contains: "missing" })).toBe(false);
|
||||
});
|
||||
|
||||
test("regex 匹配", () => {
|
||||
expect(checkBodyExpect("status: ok", { regex: "ok" })).toBe(true);
|
||||
expect(checkBodyExpect("status: error", { regex: "ok" })).toBe(false);
|
||||
});
|
||||
|
||||
test("json 简单等值匹配", () => {
|
||||
const body = JSON.stringify({ status: "ok", code: 0 });
|
||||
expect(checkBodyExpect(body, { json: { "$.status": "ok" } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.code": 0 } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.status": "error" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("json 操作符匹配", () => {
|
||||
const body = JSON.stringify({ count: 42, version: "v2.1.0", message: "success" });
|
||||
expect(checkBodyExpect(body, { json: { "$.count": { gte: 10 } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.version": { match: "\\d+\\.\\d+\\.\\d+" } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.message": { contains: "success" } } })).toBe(true);
|
||||
expect(checkBodyExpect(body, { json: { "$.count": { gte: 100 } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("json 路径不存在", () => {
|
||||
const body = JSON.stringify({ status: "ok" });
|
||||
expect(checkBodyExpect(body, { json: { "$.notExist": "value" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("json 解析失败", () => {
|
||||
expect(checkBodyExpect("not json", { json: { "$.status": "ok" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("css textContent 匹配", () => {
|
||||
const html = "<div id='health'>OK</div><span class='ver'>1.0</span>";
|
||||
expect(checkBodyExpect(html, { css: { "div#health": "OK" } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "span.ver": "1.0" } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "div#health": "ERROR" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("css 选择器无匹配元素", () => {
|
||||
const html = "<div>OK</div>";
|
||||
expect(checkBodyExpect(html, { css: { "span.missing": "OK" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("css attr 提取", () => {
|
||||
const html = '<meta name="version" content="2.0.1"><link rel="icon" href="/favicon.ico">';
|
||||
expect(checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", equals: "2.0.1" } } })).toBe(true);
|
||||
expect(
|
||||
checkBodyExpect(html, { css: { 'meta[name="version"]': { attr: "content", match: "\\d+\\.\\d+\\.\\d+" } } }),
|
||||
).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { 'link[rel="icon"]': { attr: "href", contains: "favicon" } } })).toBe(true);
|
||||
});
|
||||
|
||||
test("css exists 检查", () => {
|
||||
const html = "<div id='test'>OK</div>";
|
||||
expect(checkBodyExpect(html, { css: { "div#test": { exists: true } } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "span#missing": { exists: false } } })).toBe(true);
|
||||
expect(checkBodyExpect(html, { css: { "div#test": { exists: false } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 节点文本匹配", () => {
|
||||
const xml = "<root><status>ok</status><code>200</code></root>";
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true);
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 无匹配节点", () => {
|
||||
const xml = "<root><status>ok</status></root>";
|
||||
expect(checkBodyExpect(xml, { xpath: { "/root/missing/text()": "ok" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("xpath 包含匹配", () => {
|
||||
const html = "<html><body><div id='msg'>success</div></body></html>";
|
||||
expect(checkBodyExpect(html, { xpath: { "//div[@id='msg']/text()": "success" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("多种 body 方法 AND 组合", () => {
|
||||
const body = JSON.stringify({ status: "healthy", count: 5 });
|
||||
expect(
|
||||
checkBodyExpect(body, {
|
||||
contains: "healthy",
|
||||
json: { "$.status": "healthy", "$.count": { gte: 1 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("多种 body 方法部分失败", () => {
|
||||
const body = JSON.stringify({ status: "error" });
|
||||
expect(
|
||||
checkBodyExpect(body, {
|
||||
contains: "healthy",
|
||||
json: { "$.status": "error" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,8 @@ targets:
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200, 201]
|
||||
bodyContains: "ok"
|
||||
body:
|
||||
contains: "ok"
|
||||
maxLatencyMs: 3000
|
||||
`,
|
||||
);
|
||||
@@ -223,7 +224,7 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.expect).toEqual({
|
||||
status: [200, 201],
|
||||
bodyContains: "ok",
|
||||
body: { contains: "ok" },
|
||||
maxLatencyMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("ProbeEngine", () => {
|
||||
test("单次拨测写入数据库", async () => {
|
||||
const engine = new ProbeEngine(store, [target]);
|
||||
// 手动调用 probeGroup 不启动 timer
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([target]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
@@ -78,7 +80,9 @@ describe("ProbeEngine", () => {
|
||||
store.syncTargets([target, badTarget]);
|
||||
|
||||
const engine = new ProbeEngine(store, [target, badTarget]);
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(
|
||||
engine,
|
||||
);
|
||||
await probeGroup([target, badTarget]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
|
||||
@@ -1,33 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkExpect } from "../../../src/server/checker/fetcher";
|
||||
|
||||
const emptyHeaders: Record<string, string> = {};
|
||||
|
||||
describe("checkExpect", () => {
|
||||
test("无 expect 配置时 matched 为 true", () => {
|
||||
expect(checkExpect(200, "ok", 100, undefined)).toBe(true);
|
||||
expect(checkExpect(200, "ok", 100, emptyHeaders, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("status 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(201, "", 100, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(404, "", 100, { status: [200, 201] })).toBe(false);
|
||||
expect(checkExpect(200, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(201, "", 100, emptyHeaders, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(404, "", 100, emptyHeaders, { status: [200, 201] })).toBe(false);
|
||||
});
|
||||
|
||||
test("bodyContains 匹配", () => {
|
||||
expect(checkExpect(200, "hello world", 100, { bodyContains: "hello" })).toBe(true);
|
||||
expect(checkExpect(200, "hello world", 100, { bodyContains: "missing" })).toBe(false);
|
||||
test("headers 匹配", () => {
|
||||
const headers = { "content-type": "application/json", "x-custom": "test" };
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "application/json" } })).toBe(true);
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "Content-Type": "text/html" } })).toBe(false);
|
||||
expect(checkExpect(200, "", 100, headers, { headers: { "X-Missing": "test" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.contains 匹配", () => {
|
||||
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "hello" } })).toBe(true);
|
||||
expect(checkExpect(200, "hello world", 100, emptyHeaders, { body: { contains: "missing" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.regex 匹配", () => {
|
||||
expect(checkExpect(200, "status: ok", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(true);
|
||||
expect(checkExpect(200, "status: error", 100, emptyHeaders, { body: { regex: "status.*ok" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body.json 匹配", () => {
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, emptyHeaders, { body: { json: { "$.status": "ok" } } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "error" }), 100, emptyHeaders, {
|
||||
body: { json: { "$.status": "ok" } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("body.json 解析失败", () => {
|
||||
expect(checkExpect(200, "not json", 100, emptyHeaders, { body: { json: { "$.status": "ok" } } })).toBe(false);
|
||||
});
|
||||
|
||||
test("body 多种方法 AND 组合", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
body: {
|
||||
contains: "healthy",
|
||||
regex: "healthy",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
body: {
|
||||
contains: "healthy",
|
||||
regex: "unhealthy",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("maxLatencyMs 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, { maxLatencyMs: 200 })).toBe(true);
|
||||
expect(checkExpect(200, "", 300, { maxLatencyMs: 200 })).toBe(false);
|
||||
expect(checkExpect(200, "", 200, { maxLatencyMs: 200 })).toBe(true);
|
||||
expect(checkExpect(200, "", 100, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
|
||||
expect(checkExpect(200, "", 300, emptyHeaders, { maxLatencyMs: 200 })).toBe(false);
|
||||
expect(checkExpect(200, "", 200, emptyHeaders, { maxLatencyMs: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
test("多条 expect 全部通过", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, {
|
||||
checkExpect(200, "healthy", 100, emptyHeaders, {
|
||||
status: [200],
|
||||
bodyContains: "healthy",
|
||||
body: { contains: "healthy" },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -35,9 +84,33 @@ describe("checkExpect", () => {
|
||||
|
||||
test("多条 expect 部分失败", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 500, {
|
||||
checkExpect(200, "healthy", 500, emptyHeaders, {
|
||||
status: [200],
|
||||
bodyContains: "healthy",
|
||||
body: { contains: "healthy" },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("status + headers + body + maxLatencyMs 全组合", () => {
|
||||
const headers = { "content-type": "application/json" };
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
|
||||
status: [200],
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { contains: "ok", json: { "$.status": "ok" } },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("全组合中 headers 失败", () => {
|
||||
const headers = { "content-type": "text/html" };
|
||||
expect(
|
||||
checkExpect(200, JSON.stringify({ status: "ok" }), 100, headers, {
|
||||
status: [200],
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: { contains: "ok", json: { "$.status": "ok" } },
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user