/** * 节点 / 集合 / 环境的默认工厂。 */ import { normalizeMockSettings } from "./mock"; import type { AuthConfig, Collection, CollectionNode, Environment, FolderNode, GraphqlRequestData, HttpRequestData, KVRow, MqttRequestData, RequestKind, RequestNode, RequestDataOf, SocketIoRequestData, SseRequestData, WebsocketRequestData, WorkspaceData, TestCase, TestStep, } from "./model"; import { newId } from "./tree"; export function kv( key = "", value = "", enabled = true, description?: string, ): KVRow { return { id: newId(), key, value, enabled, description }; } export const noAuth: AuthConfig = { mode: "none" }; export function defaultRequestData(kind: RequestKind) { switch (kind) { case "http": return { kind, method: "GET", url: "/", query: [], headers: [], cookies: [], auth: { ...noAuth }, bodyMode: "none", bodyRaw: "", bodyForm: [], } satisfies HttpRequestData; case "graphql": return { kind, url: "/graphql", headers: [kv("Content-Type", "application/json")], auth: { ...noAuth }, query: "query {\n \n}", variables: "{}", } satisfies GraphqlRequestData; case "websocket": return { kind, url: "wss://echo.websocket.org", headers: [], protocols: "", } satisfies WebsocketRequestData; case "sse": return { kind, url: "/events", headers: [kv("Accept", "text/event-stream")], query: [], auth: { ...noAuth }, lastEventId: "", dataOnly: true, } satisfies SseRequestData; case "socketio": return { kind, url: "http://127.0.0.1:3000", options: '{\n "path": "/socket.io",\n "transports": ["websocket"]\n}', listenEvents: ["message"], sendEvent: "message", sendArgs: '[\n "hello"\n]', } satisfies SocketIoRequestData; case "mqtt": return { kind, brokerUrl: "wss://broker.emqx.io:8084/mqtt", username: "", password: "", clientId: `opsxapi-${Math.random().toString(16).slice(2, 10)}`, subscribeTopics: "opsxapi/demo", publishTopic: "opsxapi/demo", publishPayload: '{\n "hello": "opsxapi"\n}', qos: 0, } satisfies MqttRequestData; } } export function createRequest( kind: K, name?: string, partial?: { data?: Partial>; description?: string }, ): Extract { const data = { ...defaultRequestData(kind), ...(partial?.data ?? {}), kind, } as RequestDataOf; const base = { id: newId(), type: "request" as const, name: name ?? defaultName(kind), description: partial?.description, createdAt: Date.now(), updatedAt: Date.now(), }; return { ...base, kind, data } as unknown as Extract; } export function defaultName(kind: RequestKind): string { switch (kind) { case "http": return "新建 HTTP 接口"; case "graphql": return "新建 GraphQL 查询"; case "websocket": return "新建 WebSocket"; case "sse": return "新建 SSE"; case "socketio": return "新建 Socket.IO"; case "mqtt": return "新建 MQTT"; } } export function createFolder(name = "新建目录", children: CollectionNode[] = []): FolderNode { return { id: newId(), type: "folder", name, children, createdAt: Date.now(), updatedAt: Date.now(), }; } export function createCollection( name: string, children: CollectionNode[] = [], ): Collection { return { id: newId(), type: "collection", name, children, createdAt: Date.now(), updatedAt: Date.now(), }; } export function createEnvironment( name: string, vars: { key: string; value: string }[] = [], baseUrl = "", ): Environment { return { id: newId(), name, baseUrl, variables: vars.map((v) => kv(v.key, v.value)) }; } /** 首次启动的演示集合:覆盖全部协议,开箱即用 */ export function createSeedWorkspace(): WorkspaceData { const http = ( name: string, method: HttpRequestData["method"], url: string, extra?: Partial, description?: string, ): RequestNode => createRequest("http", name, { data: { method, url, ...extra }, description, }); const localBase = "http://127.0.0.1:8787/v1/echo"; // 固定引用,供种子场景用例 steps.nodeId 绑定 const echoGet = http( "回归 · GET Query", "GET", `${localBase}/get?ping=opsx`, { query: [kv("ping", "opsx")] }, "本地回显:Query 参数。需 Gin :8787。", ); const echoJson = http( "回归 · POST JSON", "POST", `${localBase}/json`, { bodyMode: "json", bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}', headers: [kv("Content-Type", "application/json")], }, ); const echoForm = http( "回归 · POST urlencoded", "POST", `${localBase}/form`, { bodyMode: "urlencoded", bodyForm: [kv("username", "logan"), kv("role", "ops")], }, ); const echoMultipart = http( "回归 · POST form-data", "POST", `${localBase}/multipart`, { bodyMode: "form-data", bodyForm: [kv("fileMeta", "demo"), kv("env", "local")], }, ); const echoHeaders = http( "回归 · 自定义 Header", "GET", `${localBase}/headers`, { headers: [kv("X-Opsx-Probe", "ok")] }, ); const echoCookies = http( "回归 · Cookie", "GET", `${localBase}/cookies`, { headers: [kv("X-Opsx-Cookie", "opsx_session=demo-cookie")] }, ); const echoBearer = http( "回归 · Bearer", "GET", `${localBase}/bearer`, { auth: { mode: "bearer", bearer: "{{token}}" } }, ); const echoBasic = http( "回归 · Basic Auth", "GET", `${localBase}/basic`, { auth: { mode: "basic", basicUser: "opsx", basicPass: "demo" } }, ); const demo = createCollection("示例集合", [ createFolder("HTTP · 本地回归(P0)", [ echoGet, echoJson, echoForm, echoMultipart, echoHeaders, echoCookies, echoBearer, echoBasic, ]), createFolder("HTTP · httpbin", [ http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"), http( "提交 JSON", "POST", "https://httpbin.org/post", { bodyMode: "json", bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}', headers: [kv("Content-Type", "application/json")], }, "POST 一个 JSON 体,观察 Pretty 视图。", ), http( "表单提交", "POST", "https://httpbin.org/post", { bodyMode: "urlencoded", bodyForm: [kv("username", "logan"), kv("role", "ops")], }, ), http( "Bearer 鉴权示例", "GET", "https://httpbin.org/bearer", { auth: { mode: "bearer", bearer: "{{token}}" }, }, "在 Auth 标签填 Token;环境变量 token 默认 demo-token。", ), http("状态码 · 404", "GET", "https://httpbin.org/status/404"), ]), createFolder("GraphQL", [ createRequest("graphql", "国家查询 · countries", { data: { url: "https://countries.trevorblades.com/graphql", query: "query ($code: ID!) {\n country(code: $code) {\n name\n capital\n currency\n }\n}", variables: '{\n "code": "CN"\n}', }, description: "公共 GraphQL 服务,无需鉴权。", }), ]), createFolder("实时协议", [ createRequest("websocket", "WebSocket · echo", { data: { url: "wss://echo.websocket.org" }, description: "发送什么就回什么。", }), createRequest("sse", "SSE · 维基媒体变更流", { data: { url: "https://stream.wikimedia.org/v2/stream/recentchange", headers: [kv("Accept", "text/event-stream")], }, description: "维基百科实时编辑事件流。", }), createRequest("mqtt", "MQTT · EMQX 公共 Broker", { data: { subscribeTopics: "opsxapi/demo", publishTopic: "opsxapi/demo", }, description: "wss 公共 broker;两个窗口同时打开可互相收发。", }), createRequest("socketio", "Socket.IO · 本地服务", { description: "连你的本地 socket.io 服务,或改 URL 到任意 demo 站点。", }), ]), ]); const step = (nodeId: string, assertions: TestStep["assertions"], extracts: TestStep["extracts"] = []): TestStep => ({ id: newId(), enabled: true, nodeId, assertions, extracts, }); const as = ( target: "status" | "timeMs" | "header" | "body", op: TestStep["assertions"][number]["op"], expected: string, locator = "", ) => ({ id: newId(), enabled: true, target, locator, op, expected, }); const regressionCase: TestCase = { id: "tc-opsx-http8-regression", name: "HTTP 八组合回归(本地 echo)", description: "对 Gin /v1/echo 的 8 种请求组合:Query / JSON / urlencoded / form-data / Header / Cookie / Bearer / Basic。请先 ./run.sh web 并切换环境到「本地」。", continueOnFailure: true, steps: [ step(echoGet.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.ok"), as("body", "jsonpath_eq", "opsx", "$.query.ping")]), step(echoJson.id, [as("status", "eq", "200"), as("body", "contains", "opsxapi")]), step(echoForm.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "logan", "$.form.username")]), step(echoMultipart.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo", "$.form.fileMeta")]), step(echoHeaders.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "ok", "$.xOpsxProbe")]), step(echoCookies.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo-cookie", "$.cookies.opsx_session")]), step(echoBearer.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.authenticated")]), step(echoBasic.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "opsx", "$.user")]), ], createdAt: Date.now(), updatedAt: Date.now(), }; const envs = [ createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"), createEnvironment("本地", [{ key: "token", value: "demo-token" }], "http://127.0.0.1:8787"), ]; return { version: 2, collections: [demo], environments: envs, activeEnvironmentId: envs[1]?.id ?? envs[0]?.id ?? null, history: [], settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } }, testCases: [regressionCase], testRuns: [], schedules: [], }; }