- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
/**
|
||
* 节点 / 集合 / 环境的默认工厂。
|
||
*/
|
||
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<K extends RequestKind>(
|
||
kind: K,
|
||
name?: string,
|
||
partial?: { data?: Partial<RequestDataOf<K>>; description?: string },
|
||
): Extract<RequestNode, { kind: K }> {
|
||
const data = {
|
||
...defaultRequestData(kind),
|
||
...(partial?.data ?? {}),
|
||
kind,
|
||
} as RequestDataOf<K>;
|
||
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<RequestNode, { kind: K }>;
|
||
}
|
||
|
||
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<HttpRequestData>,
|
||
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: [],
|
||
};
|
||
}
|