From 599d973cbd36c8fc7eeb0b4d33d30fc350e20372 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 10 May 2026 00:10:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20expect=20=E8=A7=84?= =?UTF-8?q?=E5=88=99=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=A7=8D=20body=20=E6=A0=A1=E9=AA=8C=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=92=8C=E6=93=8D=E4=BD=9C=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- README.md | 44 +++- bun.lock | 53 +++++ openspec/config.yaml | 3 + openspec/specs/expect-body-checkers/spec.md | 117 ++++++++++ openspec/specs/probe-config/spec.md | 20 ++ openspec/specs/probe-engine/spec.md | 39 +++- package.json | 5 +- probes.example.yaml | 20 ++ scripts/smoke.ts | 2 +- src/server/app.ts | 16 +- src/server/checker/body-expect.ts | 188 ++++++++++++++++ src/server/checker/fetcher.ts | 33 ++- src/server/checker/store.ts | 26 ++- src/server/checker/types.ts | 27 ++- src/web/app.tsx | 6 +- src/web/components/TargetDetail.tsx | 20 +- src/web/components/TrendChart.tsx | 36 ++- tests/server/app.test.ts | 4 +- tests/server/checker/body-expect.test.ts | 230 ++++++++++++++++++++ tests/server/checker/config-loader.test.ts | 5 +- tests/server/checker/engine.test.ts | 8 +- tests/server/checker/fetcher.test.ts | 101 +++++++-- 22 files changed, 923 insertions(+), 80 deletions(-) create mode 100644 openspec/specs/expect-body-checkers/spec.md create mode 100644 src/server/checker/body-expect.ts create mode 100644 tests/server/checker/body-expect.test.ts diff --git a/README.md b/README.md index 8fab0db..9223492 100644 --- a/README.md +++ b/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` | 指定目标的按小时聚合趋势 | ## 代码质量 diff --git a/bun.lock b/bun.lock index bdf1b11..4ec8fd8 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/openspec/config.yaml b/openspec/config.yaml index 4c73a8b..049f339 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -19,3 +19,6 @@ rules: - 先前的讨论技术方案要尽可能体现在设计文档中,便于指导实现阶段不偏离已定的技术路线 tasks: - 一行一个任务,严禁任务内容跨行 + - 如果是代码存在更新必须 + - 执行完整的测试、代码检查、格式检查等质量保障手段 + - 更新README文档 diff --git a/openspec/specs/expect-body-checkers/spec.md b/openspec/specs/expect-body-checkers/spec.md new file mode 100644 index 0000000..64a8c13 --- /dev/null +++ b/openspec/specs/expect-body-checkers/spec.md @@ -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 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index cc6c3b5..02fbb55 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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"` diff --git a/openspec/specs/probe-engine/spec.md b/openspec/specs/probe-engine/spec.md index f3c5359..bab53db 100644 --- a/openspec/specs/probe-engine/spec.md +++ b/openspec/specs/probe-engine/spec.md @@ -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 字段。 diff --git a/package.json b/package.json index 7d63314..827943d 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/probes.example.yaml b/probes.example.yaml index 0539a98..c24d393 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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" diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 5f5e18d..8c3f7da 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -43,7 +43,7 @@ try { const { body: summary } = await expectJson(`${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 应返回数组"); diff --git a/src/server/app.ts b/src/server/app.ts index 8410613..a4afe6b 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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) { diff --git a/src/server/checker/body-expect.ts b/src/server/checker/body-expect.ts new file mode 100644 index 0000000..1477b1d --- /dev/null +++ b/src/server/checker/body-expect.ts @@ -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 => 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)?.[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)[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): 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): 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): boolean { + let doc: ReturnType; + 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; +} diff --git a/src/server/checker/fetcher.ts b/src/server/checker/fetcher.ts index d7041b3..90ddb44 100644 --- a/src/server/checker/fetcher.ts +++ b/src/server/checker/fetcher.ts @@ -1,4 +1,5 @@ import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types"; +import { checkBodyExpect } from "./body-expect"; export async function fetchTarget(target: ResolvedTarget): Promise { const timestamp = new Date().toISOString(); @@ -17,8 +18,9 @@ export async function fetchTarget(target: ResolvedTarget): Promise 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 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 } } -export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean { +function headersToRecord(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +export function checkExpect( + statusCode: number, + body: string, + latencyMs: number, + responseHeaders: Record, + 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; } diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 131e9b4..2e4fac6 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -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(); } diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 8bb67e7..fd34882 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -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; + css?: Record; + xpath?: Record; +} + export interface ExpectConfig { status?: number[]; - bodyContains?: string; maxLatencyMs?: number; + headers?: Record; + body?: BodyExpectConfig; } export interface ResolvedTarget { diff --git a/src/web/app.tsx b/src/web/app.tsx index 83f0e4d..67c88a9 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -16,11 +16,7 @@ export function App() {

HTTP 拨测监控面板

- {error && ( -
- 请求失败: {error},将在下一次轮询周期自动重试 -
- )} + {error &&
请求失败: {error},将在下一次轮询周期自动重试
} diff --git a/src/web/components/TargetDetail.tsx b/src/web/components/TargetDetail.tsx index 3aabb1b..a9f37b9 100644 --- a/src/web/components/TargetDetail.tsx +++ b/src/web/components/TargetDetail.tsx @@ -40,9 +40,7 @@ export function TargetDetail({ target }: TargetDetailProps) {
状态 - - {isUp ? "UP" : "DOWN"} - + {isUp ? "UP" : "DOWN"}
可用率 @@ -80,18 +78,10 @@ export function TargetDetail({ target }: TargetDetailProps) { {item.success && item.matched ? "UP" : "DOWN"} - - {new Date(item.timestamp).toLocaleString("zh-CN")} - - {item.statusCode && ( - {item.statusCode} - )} - {item.latencyMs !== null && ( - {Math.round(item.latencyMs)}ms - )} - {item.error && ( - {item.error} - )} + {new Date(item.timestamp).toLocaleString("zh-CN")} + {item.statusCode && {item.statusCode}} + {item.latencyMs !== null && {Math.round(item.latencyMs)}ms} + {item.error && {item.error}}
))}
diff --git a/src/web/components/TrendChart.tsx b/src/web/components/TrendChart.tsx index 2fae47b..db10ff6 100644 --- a/src/web/components/TrendChart.tsx +++ b/src/web/components/TrendChart.tsx @@ -26,8 +26,20 @@ export function TrendChart({ data, loading }: TrendChartProps) { - - + + { const num = Number(value); @@ -37,8 +49,24 @@ export function TrendChart({ data, loading }: TrendChartProps) { return [String(value), nameStr]; }} /> - - + + diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 7608eb6..861ad71 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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); diff --git a/tests/server/checker/body-expect.test.ts b/tests/server/checker/body-expect.test.ts new file mode 100644 index 0000000..fddef1c --- /dev/null +++ b/tests/server/checker/body-expect.test.ts @@ -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 = "
OK
1.0"; + 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 = "
OK
"; + expect(checkBodyExpect(html, { css: { "span.missing": "OK" } })).toBe(false); + }); + + test("css attr 提取", () => { + const html = ''; + 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 = "
OK
"; + 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 = "ok200"; + expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "ok" } })).toBe(true); + expect(checkBodyExpect(xml, { xpath: { "/root/status/text()": "error" } })).toBe(false); + }); + + test("xpath 无匹配节点", () => { + const xml = "ok"; + expect(checkBodyExpect(xml, { xpath: { "/root/missing/text()": "ok" } })).toBe(false); + }); + + test("xpath 包含匹配", () => { + const html = "
success
"; + 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); + }); +}); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index dbfc8ef..b1681e7 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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, }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 426b13f..b65b50a 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -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 }).probeGroup.bind(engine); + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).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 }).probeGroup.bind(engine); + const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise }).probeGroup.bind( + engine, + ); await probeGroup([target, badTarget]); const dbTargets = store.getTargets(); diff --git a/tests/server/checker/fetcher.test.ts b/tests/server/checker/fetcher.test.ts index 7e138ff..afac775 100644 --- a/tests/server/checker/fetcher.test.ts +++ b/tests/server/checker/fetcher.test.ts @@ -1,33 +1,82 @@ import { describe, expect, test } from "bun:test"; import { checkExpect } from "../../../src/server/checker/fetcher"; +const emptyHeaders: Record = {}; + 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);