1
0

feat: WS checker,支持可达性检测和单次请求-响应交互验证

This commit is contained in:
2026-05-25 14:13:43 +08:00
parent 714b635aef
commit c1db793073
20 changed files with 2339 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) |
| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) |
| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) |
| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) |
## 选择建议
@@ -29,6 +30,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
| 主机可达性、延迟、丢包率 | `icmp` |
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
| LLM API 是否可用、输出是否符合预期 | `llm` |
| WebSocket 可达性或消息交互验证 | `ws` |
## 通用字段

81
docs/user/checkers/ws.md Normal file
View 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
```

View File

@@ -121,7 +121,7 @@ targets:
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 | 无 |
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm` | 是 | 无 |
| `type` | 目标类型:`http``cmd``db``tcp``udp``dns``icmp``llm``ws` | 是 | 无 |
| `group` | 分组名称 | 否 | `default` |
| `interval` | 拨测间隔 | 否 | `30s` |
| `timeout` | 超时时间 | 否 | `10s` |

View File

@@ -50,12 +50,13 @@ API 返回的检查结果包含 `detail` 和 `observation`。
| ICMP | 存活结果、丢包率、平均延迟、最大延迟 |
| DNS | RCODE、记录值、TTL、flags、CNAME 链 |
| LLM | HTTP 状态、模型输出、finish reason、token usage、流式首 token 时间 |
| WS | 连接结果、连接耗时、握手头、消息内容、消息大小 |
Dashboard 基于存储的检查结果计算实时状态、可用率、耗时趋势、P95、状态条和故障段等指标。指标语义由后端应用层实现SQLite 主要负责存储、筛选、排序、分页和基础聚合。
## 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` |
| LLM http | `status -> headers -> 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

View File

@@ -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"
}
}
}
}
}
]
}

View File

@@ -327,3 +327,29 @@ targets:
finishReason: "stop"
output:
- 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

View File

@@ -96,6 +96,8 @@ function normalizeExpect(type: string, expect: unknown): unknown {
return normalizeTcpExpect(raw);
case "udp":
return normalizeUdpExpect(raw);
case "ws":
return normalizeWsExpect(raw);
default:
return expect;
}
@@ -184,4 +186,14 @@ function normalizeValue(value: unknown): unknown {
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 };

View File

@@ -7,6 +7,7 @@ import { LlmChecker } from "./llm";
import { CheckerRegistry } from "./registry";
import { TcpChecker } from "./tcp";
import { UdpChecker } from "./udp";
import { WsChecker } from "./ws";
const checkers = [
new HttpChecker(),
@@ -17,6 +18,7 @@ const checkers = [
new UdpChecker(),
new LlmChecker(),
new DnsChecker(),
new WsChecker(),
];
export function createDefaultCheckerRegistry(): CheckerRegistry {

View 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;
}

View 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" });
}

View File

@@ -0,0 +1 @@
export { WsChecker } from "./execute";

View 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 },
);
}

View 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;
}

View 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;
}

View File

@@ -72,8 +72,8 @@ describe("CheckerRegistry", () => {
const second = createDefaultCheckerRegistry();
first.register(createChecker("custom"));
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "custom"]);
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns"]);
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", "ws"]);
expect(
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
).toBe(true);

View 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");
});
});

View 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");
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});