diff --git a/docs/user/checkers/README.md b/docs/user/checkers/README.md index 4f2a997..719f1ac 100644 --- a/docs/user/checkers/README.md +++ b/docs/user/checkers/README.md @@ -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` | ## 通用字段 diff --git a/docs/user/checkers/ws.md b/docs/user/checkers/ws.md new file mode 100644 index 0000000..7b49a74 --- /dev/null +++ b/docs/user/checkers/ws.md @@ -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 +``` diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 6f22636..e8fe6fa 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -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` | diff --git a/docs/user/expectations.md b/docs/user/expectations.md index 12fef5e..f029875 100644 --- a/docs/user/expectations.md +++ b/docs/user/expectations.md @@ -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 diff --git a/probe-config.schema.json b/probe-config.schema.json index 136f428..23fcb7a 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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" + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index d16ace6..88f3d2d 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -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 diff --git a/src/server/checker/normalizer.ts b/src/server/checker/normalizer.ts index cffa841..e86f5c8 100644 --- a/src/server/checker/normalizer.ts +++ b/src/server/checker/normalizer.ts @@ -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 }; diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index add6bb8..c0b8a77 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -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 { diff --git a/src/server/checker/runner/ws/execute.ts b/src/server/checker/runner/ws/execute.ts new file mode 100644 index 0000000..d548c9d --- /dev/null +++ b/src/server/checker/runner/ws/execute.ts @@ -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; ok: true; ws: WebSocket }; + +export class WsChecker implements CheckerDefinition { + readonly configKey = "ws"; + + readonly schemas = wsCheckerSchemas; + + readonly type = "ws"; + + buildDetail(observation: Record): 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 { + 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 = { + 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 = { + 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 = { + 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 { + if (signal.aborted) { + return { error: "连接已取消", ok: false }; + } + + let settled = false; + let resolveFn: ((result: WsConnectResult) => void) | undefined; + const connectPromise = new Promise((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)["headers"] = config.headers; + } + if (config.ignoreSSL) { + (wsOptions as Record)["tls"] = { rejectUnauthorized: false }; + } + if (config.subprotocols.length > 0) { + (wsOptions as Record)["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 = {}; + 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 { + let settled = false; + let resolveFn: ((result: MessageReceiveResult) => void) | undefined; + const messagePromise = new Promise((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; +} diff --git a/src/server/checker/runner/ws/expect.ts b/src/server/checker/runner/ws/expect.ts new file mode 100644 index 0000000..c2bd075 --- /dev/null +++ b/src/server/checker/runner/ws/expect.ts @@ -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, + expectations: KeyedExpectations | undefined, +): ExpectationResult { + return checkHeaderExpectations(headers, expectations); +} + +export function checkMessage(message: string, expectations: ContentExpectations): ExpectationResult { + return checkContentExpectations(message, expectations, { path: "message", phase: "message" }); +} diff --git a/src/server/checker/runner/ws/index.ts b/src/server/checker/runner/ws/index.ts new file mode 100644 index 0000000..2e93f5b --- /dev/null +++ b/src/server/checker/runner/ws/index.ts @@ -0,0 +1 @@ +export { WsChecker } from "./execute"; diff --git a/src/server/checker/runner/ws/schema.ts b/src/server/checker/runner/ws/schema.ts new file mode 100644 index 0000000..9a899c1 --- /dev/null +++ b/src/server/checker/runner/ws/schema.ts @@ -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 }, + ); +} diff --git a/src/server/checker/runner/ws/types.ts b/src/server/checker/runner/ws/types.ts new file mode 100644 index 0000000..7afbd8e --- /dev/null +++ b/src/server/checker/runner/ws/types.ts @@ -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; + 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; + ignoreSSL?: boolean; + maxMessageBytes?: number | string; + receiveTimeout?: number; + send?: string; + subprotocols?: string[]; + url: string; +} diff --git a/src/server/checker/runner/ws/validate.ts b/src/server/checker/runner/ws/validate.ts new file mode 100644 index 0000000..a9dd353 --- /dev/null +++ b/src/server/checker/runner/ws/validate.ts @@ -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 | undefined { + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; +} + +function validateWsExpect(target: Record, 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, 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; +} diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index 5131355..e101441 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -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); diff --git a/tests/server/checker/runner/ws/config-loader.test.ts b/tests/server/checker/runner/ws/config-loader.test.ts new file mode 100644 index 0000000..09a96af --- /dev/null +++ b/tests/server/checker/runner/ws/config-loader.test.ts @@ -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"); + }); +}); diff --git a/tests/server/checker/runner/ws/execute.test.ts b/tests/server/checker/runner/ws/execute.test.ts new file mode 100644 index 0000000..d021c46 --- /dev/null +++ b/tests/server/checker/runner/ws/execute.test.ts @@ -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 { + return { + signal: AbortSignal.timeout(15000), + ...overrides, + }; +} + +function makeWsTarget(overrides?: Partial): 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; +let noReplyServer: ReturnType; +let rejectServer: ReturnType; + +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"); + }); +}); diff --git a/tests/server/checker/runner/ws/resolve.test.ts b/tests/server/checker/runner/ws/resolve.test.ts new file mode 100644 index 0000000..3f8bb3e --- /dev/null +++ b/tests/server/checker/runner/ws/resolve.test.ts @@ -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["resolve"]>): ResolvedWsTarget { + return resolved as ResolvedWsTarget; +} + +function makeRawTarget(overrides?: Partial): RawTargetConfig { + return { + id: "test-ws", + type: "ws", + ws: { url: "ws://example.com/ws" }, + ...overrides, + }; +} + +function makeResolveContext(overrides?: Partial): 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; + expect(config["url"]).toBe("ws://example.com/ws"); + expect(config["ignoreSSL"]).toBe(false); + expect(config["receiveTimeout"]).toBe(5000); + }); +}); diff --git a/tests/server/checker/runner/ws/schema.test.ts b/tests/server/checker/runner/ws/schema.test.ts new file mode 100644 index 0000000..a50e6e2 --- /dev/null +++ b/tests/server/checker/runner/ws/schema.test.ts @@ -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(); + }); +}); diff --git a/tests/server/checker/runner/ws/validate.test.ts b/tests/server/checker/runner/ws/validate.test.ts new file mode 100644 index 0000000..f46a851 --- /dev/null +++ b/tests/server/checker/runner/ws/validate.test.ts @@ -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); + }); +});