feat: WS checker,支持可达性检测和单次请求-响应交互验证
This commit is contained in:
@@ -16,6 +16,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
|||||||
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
|
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
|
||||||
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
|
||||||
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
|
||||||
|
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
|
||||||
|
|
||||||
## 选择建议
|
## 选择建议
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
|||||||
| 主机可达性、延迟、丢包率 | `icmp` |
|
| 主机可达性、延迟、丢包率 | `icmp` |
|
||||||
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
||||||
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
||||||
|
| WebSocket 可达性或消息交互验证 | `ws` |
|
||||||
|
|
||||||
## 通用字段
|
## 通用字段
|
||||||
|
|
||||||
|
|||||||
81
docs/user/checkers/ws.md
Normal file
81
docs/user/checkers/ws.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# WS Checker
|
||||||
|
|
||||||
|
`type: ws` 用于 WebSocket 服务可达性检查和消息交互验证。
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| -------------------- | ---------------------------------------------- | ---- | ------- |
|
||||||
|
| `ws.url` | 目标 URL,必须以 `ws://` 或 `wss://` 开头 | 是 | 无 |
|
||||||
|
| `ws.headers` | 握手 HTTP 头 | 否 | `{}` |
|
||||||
|
| `ws.subprotocols` | 子协议协商 | 否 | `[]` |
|
||||||
|
| `ws.ignoreSSL` | 忽略 TLS 证书校验 | 否 | `false` |
|
||||||
|
| `ws.send` | 发送的 text 消息,配置后进入请求-响应模式 | 否 | 无 |
|
||||||
|
| `ws.receiveTimeout` | 等待响应超时,毫秒 | 否 | `5000` |
|
||||||
|
| `ws.maxMessageBytes` | 单条消息最大字节数,支持 `KB`、`MB`、`GB` 单位 | 否 | `4KB` |
|
||||||
|
|
||||||
|
## expect 校验项
|
||||||
|
|
||||||
|
| 字段 | 说明 | 必填 | 默认值 |
|
||||||
|
| ------------------ | --------------------------------------------------------------------- | ---- | ------ |
|
||||||
|
| `connected` | 期望连接结果,`true` 可达或 `false` 期望不可达 | 否 | `true` |
|
||||||
|
| `handshakeHeaders` | 握手响应头校验,使用 `KeyedExpectations` | 否 | 无 |
|
||||||
|
| `message` | 收到的消息内容校验,使用 `ContentExpectations` 数组,需配置 `ws.send` | 否 | 无 |
|
||||||
|
| `connectTimeMs` | 连接建立耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||||
|
|
||||||
|
## 两种模式
|
||||||
|
|
||||||
|
不配置 `ws.send` 时只做可达性检查(连接后立即关闭),配置 `ws.send` 后进入请求-响应模式(发送消息并等待首条响应)。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
可达性检查:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://api.example.com/ws"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
带鉴权的请求-响应:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-echo"
|
||||||
|
name: "WebSocket Echo 检查"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.example.com/ws"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer ${TOKEN}"
|
||||||
|
subprotocols: ["json"]
|
||||||
|
send: '{"action":"ping"}'
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
handshakeHeaders:
|
||||||
|
Sec-WebSocket-Protocol:
|
||||||
|
equals: "json"
|
||||||
|
message:
|
||||||
|
- json:
|
||||||
|
path: "$.action"
|
||||||
|
equals: "pong"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
期望不可达:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: "ws-internal-down"
|
||||||
|
name: "内部服务已下线"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://internal.monitor:9443/ws"
|
||||||
|
expect:
|
||||||
|
connected: false
|
||||||
|
```
|
||||||
@@ -121,7 +121,7 @@ targets:
|
|||||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
||||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
||||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
||||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm` | 是 | 无 |
|
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws` | 是 | 无 |
|
||||||
| `group` | 分组名称 | 否 | `default` |
|
| `group` | 分组名称 | 否 | `default` |
|
||||||
| `interval` | 拨测间隔 | 否 | `30s` |
|
| `interval` | 拨测间隔 | 否 | `30s` |
|
||||||
| `timeout` | 超时时间 | 否 | `10s` |
|
| `timeout` | 超时时间 | 否 | `10s` |
|
||||||
|
|||||||
@@ -50,12 +50,13 @@ API 返回的检查结果包含 `detail` 和 `observation`。
|
|||||||
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
|
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
|
||||||
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
|
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
|
||||||
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
|
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
|
||||||
|
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
|
||||||
|
|
||||||
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现,SQLite 主要负责存储、筛选、排序、分页和基础聚合。
|
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现,SQLite 主要负责存储、筛选、排序、分页和基础聚合。
|
||||||
|
|
||||||
## ContentExpectations
|
## ContentExpectations
|
||||||
|
|
||||||
`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result` 等返回内容字段均使用数组。
|
`body`、`stdout`、`stderr`、`banner`、`response`、`output`、`result`、`message` 等返回内容字段均使用数组。
|
||||||
|
|
||||||
| 规则 | 说明 |
|
| 规则 | 说明 |
|
||||||
| ---------- | ------------------------------------------------------ |
|
| ---------- | ------------------------------------------------------ |
|
||||||
@@ -139,6 +140,7 @@ expect:
|
|||||||
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
|
| DNS server | `responded -> rcode -> values -> valueCount -> answerCount -> ttlMin -> ttlMax -> authoritative -> recursionAvailable -> truncated -> authenticatedData -> result -> durationMs` |
|
||||||
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
| LLM http | `status -> headers -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
| LLM stream | `status -> headers -> stream.completed -> stream.firstTokenMs -> output -> finishReason -> rawFinishReason -> usage -> durationMs` |
|
||||||
|
| WS | `connected -> handshakeHeaders -> message -> connectTimeMs -> durationMs` |
|
||||||
|
|
||||||
## JSON Schema
|
## JSON Schema
|
||||||
|
|
||||||
|
|||||||
@@ -5746,6 +5746,633 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"ws"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 500,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connected": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connectTimeMs": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"durationMs": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"handshakeHeaders": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"selector"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"attr": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"xpath": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"equals": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": {},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": {},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exists": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"gt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"gte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"lte": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"regex": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"maxLength": 30,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"interval": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 30,
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "ws",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ws": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"headers": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"ignoreSSL": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"maxMessageBytes": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"receiveTimeout": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "^\\$\\{[^}]+\\}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"send": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"subprotocols": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"minLength": 1,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,3 +327,29 @@ targets:
|
|||||||
finishReason: "stop"
|
finishReason: "stop"
|
||||||
output:
|
output:
|
||||||
- contains: "OK"
|
- contains: "OK"
|
||||||
|
|
||||||
|
# ========== WS targets ==========
|
||||||
|
|
||||||
|
- id: "ws-reachability"
|
||||||
|
name: "WebSocket 服务可达"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
expect:
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|
||||||
|
- id: "ws-echo-check"
|
||||||
|
name: "WebSocket Echo 交互检查"
|
||||||
|
type: ws
|
||||||
|
group: "基础设施"
|
||||||
|
ws:
|
||||||
|
url: "wss://echo.websocket.org"
|
||||||
|
send: "hello"
|
||||||
|
receiveTimeout: 3000
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "hello"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ function normalizeExpect(type: string, expect: unknown): unknown {
|
|||||||
return normalizeTcpExpect(raw);
|
return normalizeTcpExpect(raw);
|
||||||
case "udp":
|
case "udp":
|
||||||
return normalizeUdpExpect(raw);
|
return normalizeUdpExpect(raw);
|
||||||
|
case "ws":
|
||||||
|
return normalizeWsExpect(raw);
|
||||||
default:
|
default:
|
||||||
return expect;
|
return expect;
|
||||||
}
|
}
|
||||||
@@ -184,4 +186,14 @@ function normalizeValue(value: unknown): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWsExpect(raw: ExpectRecord): ExpectRecord {
|
||||||
|
return compact(raw, {
|
||||||
|
connected: raw["connected"],
|
||||||
|
connectTimeMs: normalizeValue(raw["connectTimeMs"]),
|
||||||
|
durationMs: normalizeValue(raw["durationMs"]),
|
||||||
|
handshakeHeaders: normalizeKeyed(raw["handshakeHeaders"]),
|
||||||
|
message: normalizeContent(raw["message"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
export type { AuthoringProbeConfig, NormalizedProbeConfig };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LlmChecker } from "./llm";
|
|||||||
import { CheckerRegistry } from "./registry";
|
import { CheckerRegistry } from "./registry";
|
||||||
import { TcpChecker } from "./tcp";
|
import { TcpChecker } from "./tcp";
|
||||||
import { UdpChecker } from "./udp";
|
import { UdpChecker } from "./udp";
|
||||||
|
import { WsChecker } from "./ws";
|
||||||
|
|
||||||
const checkers = [
|
const checkers = [
|
||||||
new HttpChecker(),
|
new HttpChecker(),
|
||||||
@@ -17,6 +18,7 @@ const checkers = [
|
|||||||
new UdpChecker(),
|
new UdpChecker(),
|
||||||
new LlmChecker(),
|
new LlmChecker(),
|
||||||
new DnsChecker(),
|
new DnsChecker(),
|
||||||
|
new WsChecker(),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||||
|
|||||||
528
src/server/checker/runner/ws/execute.ts
Normal file
528
src/server/checker/runner/ws/execute.ts
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import { isError } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||||
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||||
|
import type { ResolvedWsExpectConfig, ResolvedWsTarget, WsTargetConfig } from "./types";
|
||||||
|
|
||||||
|
import { errorFailure } from "../../expect/failure";
|
||||||
|
import { checkValueExpectation } from "../../expect/value";
|
||||||
|
import { parseSize } from "../../utils";
|
||||||
|
import { checkConnected, checkHandshakeHeaders, checkMessage } from "./expect";
|
||||||
|
import { wsCheckerSchemas } from "./schema";
|
||||||
|
import { validateWsConfig } from "./validate";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MESSAGE_BYTES = 4096;
|
||||||
|
const DEFAULT_RECEIVE_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
type MessageReceiveResult = { data: string; ok: true; size: number } | { error: string; ok: false };
|
||||||
|
|
||||||
|
type WsConnectResult = { error: string; ok: false } | { headers: Record<string, string>; ok: true; ws: WebSocket };
|
||||||
|
|
||||||
|
export class WsChecker implements CheckerDefinition<ResolvedWsTarget> {
|
||||||
|
readonly configKey = "ws";
|
||||||
|
|
||||||
|
readonly schemas = wsCheckerSchemas;
|
||||||
|
|
||||||
|
readonly type = "ws";
|
||||||
|
|
||||||
|
buildDetail(observation: Record<string, unknown>): null | string {
|
||||||
|
const connected = observation["connected"];
|
||||||
|
if (connected !== true) {
|
||||||
|
const error = observation["error"];
|
||||||
|
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
|
||||||
|
}
|
||||||
|
const connectTimeMs = observation["connectTimeMs"];
|
||||||
|
const message = observation["message"];
|
||||||
|
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
|
||||||
|
if (typeof message === "string" && message.length > 0) {
|
||||||
|
parts.push(`message: ${truncateMessage(message)}`);
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(t: ResolvedWsTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const start = performance.now();
|
||||||
|
const expect = t.expect;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connectResult = await wsConnect(t.ws, ctx.signal);
|
||||||
|
|
||||||
|
if (!connectResult.ok) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: false,
|
||||||
|
connectTimeMs: null,
|
||||||
|
error: connectResult.error,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
};
|
||||||
|
if (expect?.connected === false) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", connectResult.error),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = connectResult.ws;
|
||||||
|
const connectTimeMs = Math.round(performance.now() - start);
|
||||||
|
const handshakeHeaders = connectResult.headers;
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedConnected = expect?.connected ?? true;
|
||||||
|
const connectedResult = checkConnected(true, expectedConnected);
|
||||||
|
if (!connectedResult.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: connectedResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect?.handshakeHeaders) {
|
||||||
|
const headersResult = checkHandshakeHeaders(handshakeHeaders, expect.handshakeHeaders);
|
||||||
|
if (!headersResult.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: headersResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageText: null | string = null;
|
||||||
|
let messageSize: null | number = null;
|
||||||
|
|
||||||
|
if (t.ws.send) {
|
||||||
|
const messageResult: MessageReceiveResult = await wsSendAndReceive(
|
||||||
|
ws,
|
||||||
|
t.ws.send,
|
||||||
|
t.ws.receiveTimeout,
|
||||||
|
t.ws.maxMessageBytes,
|
||||||
|
ctx.signal,
|
||||||
|
);
|
||||||
|
if (!messageResult.ok) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: messageResult.error,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: null,
|
||||||
|
messageSize: null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure("message", "message", messageResult.error),
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
messageText = truncateMessageForObservation(messageResult.data);
|
||||||
|
messageSize = messageResult.size;
|
||||||
|
|
||||||
|
if (expect?.message) {
|
||||||
|
const msgCheck = checkMessage(messageResult.data, expect.message);
|
||||||
|
if (!msgCheck.matched) {
|
||||||
|
closeWs(ws);
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: msgCheck.failure,
|
||||||
|
matched: false,
|
||||||
|
observation: {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: messageText,
|
||||||
|
messageSize,
|
||||||
|
},
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWs(ws);
|
||||||
|
|
||||||
|
const observation: Record<string, unknown> = {
|
||||||
|
connected: true,
|
||||||
|
connectTimeMs,
|
||||||
|
error: null,
|
||||||
|
handshakeHeaders,
|
||||||
|
message: messageText,
|
||||||
|
messageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (expect?.connectTimeMs) {
|
||||||
|
const ctResult = checkValueExpectation(connectTimeMs, expect.connectTimeMs, {
|
||||||
|
message: "connectTimeMs mismatch",
|
||||||
|
path: "connectTimeMs",
|
||||||
|
phase: "connect",
|
||||||
|
});
|
||||||
|
if (!ctResult.matched) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: ctResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
||||||
|
message: "durationMs mismatch",
|
||||||
|
path: "durationMs",
|
||||||
|
phase: "duration",
|
||||||
|
});
|
||||||
|
if (!durationResult.matched) {
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: durationResult.failure,
|
||||||
|
matched: false,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
observation,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
detail: null,
|
||||||
|
durationMs,
|
||||||
|
failure: errorFailure(
|
||||||
|
"connect",
|
||||||
|
"connect",
|
||||||
|
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
||||||
|
),
|
||||||
|
matched: false,
|
||||||
|
observation: null,
|
||||||
|
targetId: t.id,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedWsTarget {
|
||||||
|
const t = target as RawTargetConfig & { type: "ws"; ws: WsTargetConfig };
|
||||||
|
|
||||||
|
const maxMessageBytes = parseSize(t.ws.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES);
|
||||||
|
const receiveTimeout = t.ws.receiveTimeout ?? DEFAULT_RECEIVE_TIMEOUT;
|
||||||
|
|
||||||
|
const expect = target.expect as ResolvedWsExpectConfig | undefined;
|
||||||
|
const resolvedExpect: ResolvedWsExpectConfig = expect
|
||||||
|
? { ...expect, connected: expect.connected ?? true }
|
||||||
|
: { connected: true };
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: resolvedExpect,
|
||||||
|
group: target.group ?? "default",
|
||||||
|
id: t.id,
|
||||||
|
intervalMs: context.defaultIntervalMs,
|
||||||
|
name: t.name ?? null,
|
||||||
|
timeoutMs: context.defaultTimeoutMs,
|
||||||
|
type: "ws",
|
||||||
|
ws: {
|
||||||
|
headers: { ...(t.ws.headers ?? {}) },
|
||||||
|
ignoreSSL: t.ws.ignoreSSL ?? false,
|
||||||
|
maxMessageBytes,
|
||||||
|
receiveTimeout,
|
||||||
|
send: t.ws.send,
|
||||||
|
subprotocols: t.ws.subprotocols ?? [],
|
||||||
|
url: t.ws.url,
|
||||||
|
},
|
||||||
|
} satisfies ResolvedWsTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(t: ResolvedWsTarget): { config: string; target: string } {
|
||||||
|
return {
|
||||||
|
config: JSON.stringify({
|
||||||
|
headers: t.ws.headers,
|
||||||
|
ignoreSSL: t.ws.ignoreSSL,
|
||||||
|
maxMessageBytes: t.ws.maxMessageBytes,
|
||||||
|
receiveTimeout: t.ws.receiveTimeout,
|
||||||
|
send: t.ws.send,
|
||||||
|
subprotocols: t.ws.subprotocols,
|
||||||
|
url: t.ws.url,
|
||||||
|
}),
|
||||||
|
target: t.ws.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(input: CheckerValidationInput) {
|
||||||
|
return validateWsConfig(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWs(ws: WebSocket) {
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort close */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyConnectError(message: string): string {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
|
||||||
|
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
|
||||||
|
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
|
||||||
|
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
|
||||||
|
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
|
||||||
|
if (lower.includes("certificate") || lower.includes("cert") || lower.includes("ssl") || lower.includes("tls")) {
|
||||||
|
return "tls error: certificate verification failed";
|
||||||
|
}
|
||||||
|
if (lower.includes("401") || lower.includes("unauthorized")) return "handshake failed: unauthorized (401)";
|
||||||
|
if (lower.includes("403") || lower.includes("forbidden")) return "handshake failed: forbidden (403)";
|
||||||
|
if (lower.includes("404") || lower.includes("not found")) return "handshake failed: not found (404)";
|
||||||
|
if (lower.includes("handshake") || lower.includes("upgrade")) return `handshake failed: ${message}`;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessage(message: string, maxLen = 80): string {
|
||||||
|
if (message.length <= maxLen) return message;
|
||||||
|
return `${message.slice(0, maxLen)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessageForObservation(message: string, maxLen = 256): string {
|
||||||
|
if (message.length <= maxLen) return message;
|
||||||
|
return message.slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsConnect(config: ResolvedWsTarget["ws"], signal: AbortSignal): Promise<WsConnectResult> {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "连接已取消", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
let resolveFn: ((result: WsConnectResult) => void) | undefined;
|
||||||
|
const connectPromise = new Promise<WsConnectResult>((resolve) => {
|
||||||
|
resolveFn = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = (result: WsConnectResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolveFn!(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wsOptions: Bun.WebSocketOptions = {};
|
||||||
|
if (Object.keys(config.headers).length > 0) {
|
||||||
|
(wsOptions as Record<string, unknown>)["headers"] = config.headers;
|
||||||
|
}
|
||||||
|
if (config.ignoreSSL) {
|
||||||
|
(wsOptions as Record<string, unknown>)["tls"] = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
if (config.subprotocols.length > 0) {
|
||||||
|
(wsOptions as Record<string, unknown>)["protocols"] = config.subprotocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(config.url, wsOptions as never);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
settle({ error: "连接超时", ok: false });
|
||||||
|
closeWs(ws);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (ws.protocol) {
|
||||||
|
headers["sec-websocket-protocol"] = ws.protocol;
|
||||||
|
}
|
||||||
|
settle({ headers, ok: true, ws });
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
settle({ error: "连接失败", ok: false });
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener("close", (event) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
if (!settled) {
|
||||||
|
const code = event.code;
|
||||||
|
const reason = event.reason || "";
|
||||||
|
if (code >= 1000 && code < 2000) {
|
||||||
|
settle({
|
||||||
|
error: `handshake failed: server closed with code ${code}${reason ? `: ${reason}` : ""}`,
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
settle({ error: "连接关闭", ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await connectPromise;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { error: "连接超时", ok: false };
|
||||||
|
}
|
||||||
|
const message = isError(error) ? error.message : String(error);
|
||||||
|
return { error: simplifyConnectError(message), ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wsSendAndReceive(
|
||||||
|
ws: WebSocket,
|
||||||
|
sendText: string,
|
||||||
|
receiveTimeout: number,
|
||||||
|
maxMessageBytes: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<MessageReceiveResult> {
|
||||||
|
let settled = false;
|
||||||
|
let resolveFn: ((result: MessageReceiveResult) => void) | undefined;
|
||||||
|
const messagePromise = new Promise<MessageReceiveResult>((resolve) => {
|
||||||
|
resolveFn = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = (result: MessageReceiveResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolveFn!(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
settle({ error: `等待响应超时 (${receiveTimeout}ms)`, ok: false });
|
||||||
|
}, receiveTimeout);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
settle({ error: "探测已取消", ok: false });
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
ws.removeEventListener("message", onMessage);
|
||||||
|
ws.removeEventListener("close", onClose);
|
||||||
|
ws.removeEventListener("error", onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (settled) return;
|
||||||
|
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as Uint8Array);
|
||||||
|
const size = new TextEncoder().encode(data).byteLength;
|
||||||
|
if (size > maxMessageBytes) {
|
||||||
|
settle({ error: `消息超过 ${maxMessageBytes} 字节限制 (${size} bytes)`, ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle({ data, ok: true, size });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = (event: CloseEvent) => {
|
||||||
|
if (settled) return;
|
||||||
|
const code = event.code;
|
||||||
|
const reason = event.reason || "";
|
||||||
|
settle({ error: `服务端关闭连接: code=${code}${reason ? ` reason=${reason}` : ""}`, ok: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settle({ error: "连接错误", ok: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", onMessage);
|
||||||
|
ws.addEventListener("close", onClose);
|
||||||
|
ws.addEventListener("error", onError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws.send(sendText);
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
return { error: isError(error) ? error.message : "发送消息失败", ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await messagePromise;
|
||||||
|
cleanup();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
30
src/server/checker/runner/ws/expect.ts
Normal file
30
src/server/checker/runner/ws/expect.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ContentExpectations, ExpectationResult, KeyedExpectations } from "../../expect/types";
|
||||||
|
|
||||||
|
import { checkContentExpectations } from "../../expect/content";
|
||||||
|
import { mismatchFailure } from "../../expect/failure";
|
||||||
|
import { checkHeaderExpectations } from "../../expect/headers";
|
||||||
|
|
||||||
|
export function checkConnected(connected: boolean, expected: boolean): ExpectationResult {
|
||||||
|
if (connected === expected) return { failure: null, matched: true };
|
||||||
|
if (!connected && expected) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connect", "connected", true, false, "期望 WebSocket 连接成功但连接失败"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("connect", "connected", false, true, "期望 WebSocket 连接失败但连接成功"),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkHandshakeHeaders(
|
||||||
|
headers: Record<string, unknown>,
|
||||||
|
expectations: KeyedExpectations | undefined,
|
||||||
|
): ExpectationResult {
|
||||||
|
return checkHeaderExpectations(headers, expectations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkMessage(message: string, expectations: ContentExpectations): ExpectationResult {
|
||||||
|
return checkContentExpectations(message, expectations, { path: "message", phase: "message" });
|
||||||
|
}
|
||||||
1
src/server/checker/runner/ws/index.ts
Normal file
1
src/server/checker/runner/ws/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { WsChecker } from "./execute";
|
||||||
66
src/server/checker/runner/ws/schema.ts
Normal file
66
src/server/checker/runner/ws/schema.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import type { CheckerSchemas } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAuthoringContentExpectationsSchema,
|
||||||
|
createAuthoringFieldSchema,
|
||||||
|
createAuthoringKeyedExpectationsSchema,
|
||||||
|
createAuthoringStringMapSchema,
|
||||||
|
createAuthoringValueExpectationSchema,
|
||||||
|
createNormalizedContentExpectationsSchema,
|
||||||
|
createNormalizedKeyedExpectationsSchema,
|
||||||
|
createNormalizedValueExpectationSchema,
|
||||||
|
sizeSchema,
|
||||||
|
stringMapSchema,
|
||||||
|
} from "../../schema/fragments";
|
||||||
|
|
||||||
|
export const wsCheckerSchemas: CheckerSchemas = {
|
||||||
|
authoring: {
|
||||||
|
config: createWsConfigSchema("authoring"),
|
||||||
|
expect: createWsExpectSchema("authoring"),
|
||||||
|
},
|
||||||
|
normalized: {
|
||||||
|
config: createWsConfigSchema("normalized"),
|
||||||
|
expect: createWsExpectSchema("normalized"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createWsConfigSchema(kind: "authoring" | "normalized") {
|
||||||
|
const bool = Type.Boolean();
|
||||||
|
const timeout = Type.Number({ minimum: 0 });
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
headers: Type.Optional(kind === "authoring" ? createAuthoringStringMapSchema() : stringMapSchema),
|
||||||
|
ignoreSSL: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(bool) : bool),
|
||||||
|
maxMessageBytes: Type.Optional(sizeSchema),
|
||||||
|
receiveTimeout: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(timeout) : timeout),
|
||||||
|
send: Type.Optional(Type.String()),
|
||||||
|
subprotocols: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
||||||
|
url: Type.String({ minLength: 1 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWsExpectSchema(kind: "authoring" | "normalized") {
|
||||||
|
const connected = Type.Boolean();
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
connected: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(connected) : connected),
|
||||||
|
connectTimeMs: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||||
|
),
|
||||||
|
durationMs: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(),
|
||||||
|
),
|
||||||
|
handshakeHeaders: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringKeyedExpectationsSchema() : createNormalizedKeyedExpectationsSchema(),
|
||||||
|
),
|
||||||
|
message: Type.Optional(
|
||||||
|
kind === "authoring" ? createAuthoringContentExpectationsSchema() : createNormalizedContentExpectationsSchema(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/server/checker/runner/ws/types.ts
Normal file
55
src/server/checker/runner/ws/types.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
ContentExpectations,
|
||||||
|
KeyedExpectations,
|
||||||
|
RawContentExpectations,
|
||||||
|
RawKeyedExpectations,
|
||||||
|
RawValueExpectation,
|
||||||
|
ValueExpectation,
|
||||||
|
} from "../../expect/types";
|
||||||
|
import type { ResolvedTargetBase } from "../../types";
|
||||||
|
|
||||||
|
export interface RawWsExpectConfig {
|
||||||
|
connected?: boolean;
|
||||||
|
connectTimeMs?: RawValueExpectation;
|
||||||
|
durationMs?: RawValueExpectation;
|
||||||
|
handshakeHeaders?: RawKeyedExpectations;
|
||||||
|
message?: RawContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsConfig {
|
||||||
|
headers: Record<string, string>;
|
||||||
|
ignoreSSL: boolean;
|
||||||
|
maxMessageBytes: number;
|
||||||
|
receiveTimeout: number;
|
||||||
|
send?: string;
|
||||||
|
subprotocols: string[];
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsExpectConfig {
|
||||||
|
connected: boolean;
|
||||||
|
connectTimeMs?: ValueExpectation;
|
||||||
|
durationMs?: ValueExpectation;
|
||||||
|
handshakeHeaders?: KeyedExpectations;
|
||||||
|
message?: ContentExpectations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWsTarget extends ResolvedTargetBase {
|
||||||
|
expect?: ResolvedWsExpectConfig;
|
||||||
|
group: string;
|
||||||
|
intervalMs: number;
|
||||||
|
name: null | string;
|
||||||
|
timeoutMs: number;
|
||||||
|
type: "ws";
|
||||||
|
ws: ResolvedWsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsTargetConfig {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
ignoreSSL?: boolean;
|
||||||
|
maxMessageBytes?: number | string;
|
||||||
|
receiveTimeout?: number;
|
||||||
|
send?: string;
|
||||||
|
subprotocols?: string[];
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
215
src/server/checker/runner/ws/validate.ts
Normal file
215
src/server/checker/runner/ws/validate.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { isNumber, isString } from "es-toolkit";
|
||||||
|
|
||||||
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isPlainRecord,
|
||||||
|
validateRawContentExpectations,
|
||||||
|
validateRawKeyedExpectations,
|
||||||
|
validateRawValueExpectation,
|
||||||
|
} from "../../expect/validate";
|
||||||
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
|
|
||||||
|
const ALLOWED_PROTOCOLS = new Set(["ws:", "wss:"]);
|
||||||
|
|
||||||
|
export function validateWsConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < input.targets.length; i++) {
|
||||||
|
const target = input.targets[i] as unknown;
|
||||||
|
if (!isPlainRecord(target)) continue;
|
||||||
|
if (target["type"] !== "ws") continue;
|
||||||
|
issues.push(...validateWsTarget(target, `targets[${i}]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||||
|
if (isString(target["name"])) return target["name"];
|
||||||
|
return isString(target["id"]) ? target["id"] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWsExpect(target: Record<string, unknown>, path: string, hasSend: boolean): ConfigValidationIssue[] {
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const expect = target["expect"];
|
||||||
|
if (expect === undefined || expect === null || !isPlainRecord(expect)) return [];
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const expectPath = joinPath(path, "expect");
|
||||||
|
|
||||||
|
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedFalse = expect["connected"] === false;
|
||||||
|
|
||||||
|
if (expect["handshakeHeaders"] !== undefined) {
|
||||||
|
if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "handshakeHeaders"),
|
||||||
|
"handshakeHeaders 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(
|
||||||
|
...validateRawKeyedExpectations(
|
||||||
|
expect["handshakeHeaders"],
|
||||||
|
joinPath(expectPath, "handshakeHeaders"),
|
||||||
|
targetName,
|
||||||
|
{
|
||||||
|
caseInsensitive: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["message"] !== undefined) {
|
||||||
|
if (!hasSend) {
|
||||||
|
issues.push(issue("invalid-value", joinPath(expectPath, "message"), "message 断言需要配置 ws.send", targetName));
|
||||||
|
} else if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "message"),
|
||||||
|
"message 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(...validateRawContentExpectations(expect["message"], joinPath(expectPath, "message"), targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["connectTimeMs"] !== undefined) {
|
||||||
|
if (connectedFalse) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
joinPath(expectPath, "connectTimeMs"),
|
||||||
|
"connectTimeMs 断言需要 expect.connected 为 true",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
issues.push(
|
||||||
|
...validateRawValueExpectation(expect["connectTimeMs"], joinPath(expectPath, "connectTimeMs"), targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect["durationMs"] !== undefined) {
|
||||||
|
issues.push(...validateRawValueExpectation(expect["durationMs"], joinPath(expectPath, "durationMs"), targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedKeys = new Set(["connected", "connectTimeMs", "durationMs", "handshakeHeaders", "message"]);
|
||||||
|
for (const key of Object.keys(expect)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWsTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||||
|
const issues: ConfigValidationIssue[] = [];
|
||||||
|
const targetName = getTargetName(target);
|
||||||
|
const ws = target["ws"];
|
||||||
|
|
||||||
|
if (!isPlainRecord(ws)) {
|
||||||
|
issues.push(issue("required", joinPath(path, "ws"), "缺少 ws.url 字段", targetName));
|
||||||
|
issues.push(...validateWsExpect(target, path, false));
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(ws["url"]) || ws["url"].trim() === "") {
|
||||||
|
issues.push(issue("required", joinPath(joinPath(path, "ws"), "url"), "缺少 ws.url 字段", targetName));
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const url = new URL(ws["url"]);
|
||||||
|
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-url",
|
||||||
|
joinPath(joinPath(path, "ws"), "url"),
|
||||||
|
"格式不合法,必须以 ws:// 或 wss:// 开头",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
issues.push(issue("invalid-url", joinPath(joinPath(path, "ws"), "url"), "格式不合法", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["subprotocols"] !== undefined) {
|
||||||
|
if (!Array.isArray(ws["subprotocols"])) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-type", joinPath(joinPath(path, "ws"), "subprotocols"), "必须为字符串数组", targetName),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < ws["subprotocols"].length; i++) {
|
||||||
|
const sp = ws["subprotocols"][i] as unknown;
|
||||||
|
if (!isString(sp) || sp.trim() === "") {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"invalid-value",
|
||||||
|
`${joinPath(joinPath(path, "ws"), "subprotocols")}[${i}]`,
|
||||||
|
"必须为非空字符串",
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["ignoreSSL"] !== undefined && typeof ws["ignoreSSL"] !== "boolean") {
|
||||||
|
issues.push(issue("invalid-type", joinPath(joinPath(path, "ws"), "ignoreSSL"), "必须为布尔值", targetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ws["receiveTimeout"] !== undefined &&
|
||||||
|
!(isNumber(ws["receiveTimeout"]) && Number.isFinite(ws["receiveTimeout"]) && ws["receiveTimeout"] >= 0)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-type", joinPath(joinPath(path, "ws"), "receiveTimeout"), "必须为非负有限数字", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws["maxMessageBytes"] !== undefined) {
|
||||||
|
if (
|
||||||
|
!isString(ws["maxMessageBytes"]) &&
|
||||||
|
!(isNumber(ws["maxMessageBytes"]) && Number.isFinite(ws["maxMessageBytes"]) && ws["maxMessageBytes"] >= 0)
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue("invalid-value", joinPath(joinPath(path, "ws"), "maxMessageBytes"), "必须为合法 size 值", targetName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedWsKeys = new Set([
|
||||||
|
"headers",
|
||||||
|
"ignoreSSL",
|
||||||
|
"maxMessageBytes",
|
||||||
|
"receiveTimeout",
|
||||||
|
"send",
|
||||||
|
"subprotocols",
|
||||||
|
"url",
|
||||||
|
]);
|
||||||
|
for (const key of Object.keys(ws)) {
|
||||||
|
if (!allowedWsKeys.has(key)) {
|
||||||
|
issues.push(issue("unknown-field", joinPath(joinPath(path, "ws"), key), "是未知字段", targetName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSend = isString(ws["send"]) && ws["send"].length > 0;
|
||||||
|
issues.push(...validateWsExpect(target, path, hasSend));
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
|
|||||||
const second = createDefaultCheckerRegistry();
|
const second = createDefaultCheckerRegistry();
|
||||||
first.register(createChecker("custom"));
|
first.register(createChecker("custom"));
|
||||||
|
|
||||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
|
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "custom"]);
|
||||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
|
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws"]);
|
||||||
expect(
|
expect(
|
||||||
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
155
tests/server/checker/runner/ws/config-loader.test.ts
Normal file
155
tests/server/checker/runner/ws/config-loader.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
|
||||||
|
import { loadConfig } from "../../../../../src/server/checker/config-loader";
|
||||||
|
|
||||||
|
describe("loadConfig with ws checker", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = join(tmpdir(), `ws-cfg-test-${Date.now()}`);
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("解析最简 ws 配置", async () => {
|
||||||
|
const configPath = join(tempDir, "minimal-ws.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "ws-test"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://example.com/ws"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets).toHaveLength(1);
|
||||||
|
const t = config.targets[0]! as ResolvedWsTarget;
|
||||||
|
expect(t.type).toBe("ws");
|
||||||
|
expect(t.id).toBe("ws-test");
|
||||||
|
expect(t.ws.url).toBe("ws://example.com/ws");
|
||||||
|
expect(t.ws.headers).toEqual({});
|
||||||
|
expect(t.ws.ignoreSSL).toBe(false);
|
||||||
|
expect(t.ws.maxMessageBytes).toBe(4096);
|
||||||
|
expect(t.ws.receiveTimeout).toBe(5000);
|
||||||
|
expect(t.ws.send).toBeUndefined();
|
||||||
|
expect(t.ws.subprotocols).toEqual([]);
|
||||||
|
expect(t.expect).toEqual({ connected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("解析带 send 的 ws 配置", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-send.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "ws-echo"
|
||||||
|
name: "WS Echo 检查"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "wss://api.example.com/ws"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer token"
|
||||||
|
subprotocols:
|
||||||
|
- "json"
|
||||||
|
ignoreSSL: true
|
||||||
|
send: "ping"
|
||||||
|
receiveTimeout: 3000
|
||||||
|
maxMessageBytes: "8KB"
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "pong"
|
||||||
|
durationMs:
|
||||||
|
lte: 5000
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets).toHaveLength(1);
|
||||||
|
const t = config.targets[0]! as ResolvedWsTarget;
|
||||||
|
expect(t.type).toBe("ws");
|
||||||
|
expect(t.ws.url).toBe("wss://api.example.com/ws");
|
||||||
|
expect(t.ws.headers).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(t.ws.ignoreSSL).toBe(true);
|
||||||
|
expect(t.ws.maxMessageBytes).toBe(8192);
|
||||||
|
expect(t.ws.receiveTimeout).toBe(3000);
|
||||||
|
expect(t.ws.send).toBe("ping");
|
||||||
|
expect(t.ws.subprotocols).toEqual(["json"]);
|
||||||
|
expect(t.expect?.connected).toBe(true);
|
||||||
|
expect(t.expect?.message).toBeDefined();
|
||||||
|
expect(t.expect?.durationMs).toEqual({ lte: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws 缺少 url 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-no-url.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws: {}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("ws.url");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws url 非 ws/wss 协议抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-bad-url.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("ws:// 或 wss://");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws expect.message 未配置 send 抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "ws-no-send.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "t"
|
||||||
|
type: ws
|
||||||
|
ws:
|
||||||
|
url: "ws://example.com"
|
||||||
|
expect:
|
||||||
|
message:
|
||||||
|
- contains: "pong"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await loadConfig(configPath);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toContain("send");
|
||||||
|
});
|
||||||
|
});
|
||||||
201
tests/server/checker/runner/ws/execute.test.ts
Normal file
201
tests/server/checker/runner/ws/execute.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CheckerContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
|
||||||
|
import { WsChecker } from "../../../../../src/server/checker/runner/ws/execute";
|
||||||
|
|
||||||
|
function createEchoServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch(req, server) {
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (!success) return new Response("Upgrade failed", { status: 500 });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
websocket: {
|
||||||
|
close() {
|
||||||
|
/* ws close */
|
||||||
|
},
|
||||||
|
message(ws, message) {
|
||||||
|
ws.send(message);
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
/* ws open */
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoReplyServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch(req, server) {
|
||||||
|
const success = server.upgrade(req);
|
||||||
|
if (!success) return new Response("Upgrade failed", { status: 500 });
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
websocket: {
|
||||||
|
close() {
|
||||||
|
/* ws close */
|
||||||
|
},
|
||||||
|
message() {
|
||||||
|
/* no reply */
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
/* ws open */
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRejectServer() {
|
||||||
|
return Bun.serve({
|
||||||
|
fetch() {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(overrides?: Partial<CheckerContext>): CheckerContext {
|
||||||
|
return {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWsTarget(overrides?: Partial<ResolvedWsTarget>): ResolvedWsTarget {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
expect: { connected: true },
|
||||||
|
group: "default",
|
||||||
|
id: "test-ws",
|
||||||
|
intervalMs: 30000,
|
||||||
|
name: null,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
type: "ws",
|
||||||
|
ws: {
|
||||||
|
headers: {},
|
||||||
|
ignoreSSL: false,
|
||||||
|
maxMessageBytes: 4096,
|
||||||
|
receiveTimeout: 5000,
|
||||||
|
subprotocols: [],
|
||||||
|
url: "ws://127.0.0.1:19999/ws",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let echoServer: ReturnType<typeof createEchoServer>;
|
||||||
|
let noReplyServer: ReturnType<typeof createNoReplyServer>;
|
||||||
|
let rejectServer: ReturnType<typeof createRejectServer>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
echoServer = createEchoServer();
|
||||||
|
noReplyServer = createNoReplyServer();
|
||||||
|
rejectServer = createRejectServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await echoServer.stop();
|
||||||
|
await noReplyServer.stop();
|
||||||
|
await rejectServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WsChecker execute", () => {
|
||||||
|
const checker = new WsChecker();
|
||||||
|
|
||||||
|
test("可达性检查 - 连接成功", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${echoServer.port}` } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
expect(result.observation!["connected"]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("可达性检查 - 连接失败", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure).not.toBeNull();
|
||||||
|
expect(result.observation!["connected"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("可达性检查 - 连接失败但 expect.connected=false", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: { connected: false },
|
||||||
|
ws: { ...makeWsTarget().ws, url: "ws://127.0.0.1:1" },
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - 发送消息并收到响应", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: {
|
||||||
|
connected: true,
|
||||||
|
message: [{ kind: "value" as const, matcher: { equals: "ping" } }],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${echoServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(true);
|
||||||
|
expect(result.observation!["message"]).toBe("ping");
|
||||||
|
expect(result.observation!["messageSize"]).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - 消息不匹配", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
expect: {
|
||||||
|
connected: true,
|
||||||
|
message: [{ kind: "value" as const, matcher: { equals: "pong" } }],
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${echoServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("交互模式 - receiveTimeout 超时", async () => {
|
||||||
|
const target = makeWsTarget({
|
||||||
|
ws: {
|
||||||
|
...makeWsTarget().ws,
|
||||||
|
receiveTimeout: 500,
|
||||||
|
send: "ping",
|
||||||
|
url: `ws://127.0.0.1:${noReplyServer.port}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.failure?.phase).toBe("message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTTP 403 握手失败", async () => {
|
||||||
|
const target = makeWsTarget({ ws: { ...makeWsTarget().ws, url: `ws://127.0.0.1:${rejectServer.port}` } });
|
||||||
|
const result = await checker.execute(target, makeContext());
|
||||||
|
expect(result.matched).toBe(false);
|
||||||
|
expect(result.observation!["connected"]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDetail 连接成功", () => {
|
||||||
|
const detail = checker.buildDetail({ connected: true, connectTimeMs: 50, message: "hello" });
|
||||||
|
expect(detail).toContain("connected");
|
||||||
|
expect(detail).toContain("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildDetail 连接失败", () => {
|
||||||
|
const detail = checker.buildDetail({ connected: false, error: "connection refused" });
|
||||||
|
expect(detail).toContain("connection failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/server/checker/runner/ws/resolve.test.ts
Normal file
95
tests/server/checker/runner/ws/resolve.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { ResolveContext } from "../../../../../src/server/checker/runner/types";
|
||||||
|
import type { ResolvedWsTarget } from "../../../../../src/server/checker/runner/ws/types";
|
||||||
|
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "../../../../../src/server/checker/runner";
|
||||||
|
|
||||||
|
function asWs(resolved: ReturnType<ReturnType<typeof checkerRegistry.get>["resolve"]>): ResolvedWsTarget {
|
||||||
|
return resolved as ResolvedWsTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRawTarget(overrides?: Partial<RawTargetConfig>): RawTargetConfig {
|
||||||
|
return {
|
||||||
|
id: "test-ws",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com/ws" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResolveContext(overrides?: Partial<ResolveContext>): ResolveContext {
|
||||||
|
return {
|
||||||
|
configDir: "/tmp",
|
||||||
|
defaultIntervalMs: 30000,
|
||||||
|
defaultTimeoutMs: 10000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WsChecker resolve", () => {
|
||||||
|
const checker = checkerRegistry.tryGet("ws")!;
|
||||||
|
|
||||||
|
test("最简 target 填充默认值", () => {
|
||||||
|
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
|
||||||
|
expect(resolved.type).toBe("ws");
|
||||||
|
expect(resolved.ws.url).toBe("ws://example.com/ws");
|
||||||
|
expect(resolved.ws.headers).toEqual({});
|
||||||
|
expect(resolved.ws.ignoreSSL).toBe(false);
|
||||||
|
expect(resolved.ws.maxMessageBytes).toBe(4096);
|
||||||
|
expect(resolved.ws.receiveTimeout).toBe(5000);
|
||||||
|
expect(resolved.ws.send).toBeUndefined();
|
||||||
|
expect(resolved.ws.subprotocols).toEqual([]);
|
||||||
|
expect(resolved.expect).toEqual({ connected: true });
|
||||||
|
expect(resolved.group).toBe("default");
|
||||||
|
expect(resolved.intervalMs).toBe(30000);
|
||||||
|
expect(resolved.timeoutMs).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("完整配置正确 resolve", () => {
|
||||||
|
const raw = makeRawTarget({
|
||||||
|
expect: { connected: true, durationMs: { lte: 5000 } },
|
||||||
|
ws: {
|
||||||
|
headers: { Authorization: "Bearer token" },
|
||||||
|
ignoreSSL: true,
|
||||||
|
maxMessageBytes: "8KB",
|
||||||
|
receiveTimeout: 3000,
|
||||||
|
send: "ping",
|
||||||
|
subprotocols: ["json"],
|
||||||
|
url: "wss://api.example.com/ws",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.ws.url).toBe("wss://api.example.com/ws");
|
||||||
|
expect(resolved.ws.headers).toEqual({ Authorization: "Bearer token" });
|
||||||
|
expect(resolved.ws.ignoreSSL).toBe(true);
|
||||||
|
expect(resolved.ws.maxMessageBytes).toBe(8192);
|
||||||
|
expect(resolved.ws.receiveTimeout).toBe(3000);
|
||||||
|
expect(resolved.ws.send).toBe("ping");
|
||||||
|
expect(resolved.ws.subprotocols).toEqual(["json"]);
|
||||||
|
expect(resolved.expect?.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 默认 connected=true", () => {
|
||||||
|
const raw = makeRawTarget({ expect: { durationMs: { lte: 1000 } } });
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.expect?.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 保留", () => {
|
||||||
|
const raw = makeRawTarget({ expect: { connected: false } });
|
||||||
|
const resolved = asWs(checker.resolve(raw, makeResolveContext()));
|
||||||
|
expect(resolved.expect?.connected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serialize 返回正确格式", () => {
|
||||||
|
const resolved = asWs(checker.resolve(makeRawTarget(), makeResolveContext()));
|
||||||
|
const serialized = checker.serialize(resolved);
|
||||||
|
expect(serialized.target).toBe("ws://example.com/ws");
|
||||||
|
const config = JSON.parse(serialized.config) as Record<string, unknown>;
|
||||||
|
expect(config["url"]).toBe("ws://example.com/ws");
|
||||||
|
expect(config["ignoreSSL"]).toBe(false);
|
||||||
|
expect(config["receiveTimeout"]).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
tests/server/checker/runner/ws/schema.test.ts
Normal file
22
tests/server/checker/runner/ws/schema.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "../../../../../src/server/checker/runner";
|
||||||
|
|
||||||
|
describe("WsChecker schema", () => {
|
||||||
|
const checker = checkerRegistry.tryGet("ws");
|
||||||
|
|
||||||
|
test("ws checker 注册到 registry", () => {
|
||||||
|
expect(checker).toBeDefined();
|
||||||
|
expect(checker?.type).toBe("ws");
|
||||||
|
expect(checker?.configKey).toBe("ws");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schemas 包含 authoring 和 normalized config/expect", () => {
|
||||||
|
expect(checker).toBeDefined();
|
||||||
|
expect(Object.keys(checker!.schemas).sort()).toEqual(["authoring", "normalized"].sort());
|
||||||
|
expect(checker!.schemas.authoring.config).toBeDefined();
|
||||||
|
expect(checker!.schemas.authoring.expect).toBeDefined();
|
||||||
|
expect(checker!.schemas.normalized.config).toBeDefined();
|
||||||
|
expect(checker!.schemas.normalized.expect).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
215
tests/server/checker/runner/ws/validate.test.ts
Normal file
215
tests/server/checker/runner/ws/validate.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import type { CheckerValidationInput } from "../../../../../src/server/checker/runner/types";
|
||||||
|
|
||||||
|
import { validateWsConfig } from "../../../../../src/server/checker/runner/ws/validate";
|
||||||
|
|
||||||
|
function makeInput(targets: unknown[]): CheckerValidationInput {
|
||||||
|
return {
|
||||||
|
targets: targets as CheckerValidationInput["targets"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateWsConfig", () => {
|
||||||
|
test("合法 ws target 无错误", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "ws://example.com" } }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("合法 wss target 无错误", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "wss://example.com/ws" } }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 ws 分组", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws" }]));
|
||||||
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
|
expect(issues.some((i) => i.message.includes("ws"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("缺少 url", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: {} }]));
|
||||||
|
expect(issues.some((i) => i.path.includes("url"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url 非 ws/wss 协议报错", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "http://example.com" } }]));
|
||||||
|
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("url 格式非法报错", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { url: "not-a-url" } }]));
|
||||||
|
expect(issues.some((i) => i.code === "invalid-url")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 非数组报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: "json", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 元素为空字符串报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: [""], url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("subprotocols"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subprotocols 合法无错误", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { subprotocols: ["json", "binary"], url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignoreSSL 非布尔值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { ignoreSSL: "yes", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("ignoreSSL"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receiveTimeout 非数字报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: "slow", url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receiveTimeout 为负数报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { receiveTimeout: -1, url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("receiveTimeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxMessageBytes 非法值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([{ id: "t1", type: "ws", ws: { maxMessageBytes: -1, url: "ws://example.com" } }]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("maxMessageBytes"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ws 分组未知字段", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ id: "t1", type: "ws", ws: { tls: true, url: "ws://example.com" } }]));
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.message 未配置 ws.send 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("send"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.message 配置 ws.send 无错误", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { send: "ping", url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected 非布尔值报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: "yes" },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.path.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.message 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, message: [{ contains: "pong" }] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { send: "ping", url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.handshakeHeaders 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, handshakeHeaders: { "Sec-WebSocket-Protocol": { equals: "json" } } },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 时 expect.connectTimeMs 报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false, connectTimeMs: { lte: 1000 } },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("connected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect.connected=false 单独配置合法", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { connected: false },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expect 未知字段报错", () => {
|
||||||
|
const issues = validateWsConfig(
|
||||||
|
makeInput([
|
||||||
|
{
|
||||||
|
expect: { status: [200] },
|
||||||
|
id: "t1",
|
||||||
|
type: "ws",
|
||||||
|
ws: { url: "ws://example.com" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(issues.some((i) => i.message.includes("未知字段"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("非 ws 类型 target 跳过", () => {
|
||||||
|
const issues = validateWsConfig(makeInput([{ http: { url: "http://example.com" }, id: "t1", type: "http" }]));
|
||||||
|
expect(issues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user