定时任务 Cron 表达式支持(工作区遗留改动收尾)
- TestSchedule 新增可选 5 段 cron(分 时 日 月 周,本地时区),优先于 intervalMinutes;runLog 调度日志 - cron.ts 解析与匹配引擎 + 单测;scheduler 按最近下次触发时间调度 - ScheduleDialog / TestingView 支持 cron 输入与下次运行提示;AiCaseDialog 微调 - 服务端 internal/echo 调试回显端点;修复 model.ts 注释内 */ 导致的语法错误 - 全量 53 前端用例绿
This commit is contained in:
1 parent
ce6d2d1eff
commit
045e8c65c3
14 files changed
+657
-32
No files matched your search
@@ -107,3 +107,10 @@ Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`)
|
|||||||
make test # core 21 个单测:导入导出全格式往返、树操作、cURL 解析
|
make test # core 21 个单测:导入导出全格式往返、树操作、cURL 解析
|
||||||
cd apps/server && go test ./...
|
cd apps/server && go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## P0 自动化演示(本地)
|
||||||
|
|
||||||
|
1. `./run.sh web` → http://127.0.0.1:5173
|
||||||
|
2. 进入自动化测试 → **装载回归示例** → **运行**(依赖本机 Gin `:8787/v1/echo`)
|
||||||
|
3. AI:**保存并运行** 与手动/定时共用同一执行器与报告
|
||||||
|
4. 定时:支持 5 段 Cron 或间隔;应用保持打开;见 `docs/HANDTEST-P0.md`
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Package echo 提供本地 HTTP 回显,供自动化回归套件使用(不依赖外网 httpbin)。
|
||||||
|
package echo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount registers /v1/echo/* routes.
|
||||||
|
func Mount(r *gin.Engine) {
|
||||||
|
e := r.Group("/v1/echo")
|
||||||
|
e.GET("/get", getEcho)
|
||||||
|
e.POST("/json", jsonEcho)
|
||||||
|
e.POST("/form", formEcho)
|
||||||
|
e.POST("/multipart", multipartEcho)
|
||||||
|
e.GET("/headers", headersEcho)
|
||||||
|
e.GET("/cookies", cookiesEcho)
|
||||||
|
e.GET("/bearer", bearerEcho)
|
||||||
|
e.GET("/basic", basicEcho)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseMeta(c *gin.Context) gin.H {
|
||||||
|
hdrs := gin.H{}
|
||||||
|
for k, v := range c.Request.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
hdrs[k] = v[0]
|
||||||
|
} else {
|
||||||
|
hdrs[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := gin.H{}
|
||||||
|
for k, v := range c.Request.URL.Query() {
|
||||||
|
if len(v) == 1 {
|
||||||
|
q[k] = v[0]
|
||||||
|
} else {
|
||||||
|
q[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"service": "opsxapi-echo",
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
"query": q,
|
||||||
|
"headers": hdrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEcho(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, baseMeta(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonEcho(c *gin.Context) {
|
||||||
|
body, _ := io.ReadAll(c.Request.Body)
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["bodyRaw"] = string(body)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formEcho(c *gin.Context) {
|
||||||
|
_ = c.Request.ParseForm()
|
||||||
|
form := gin.H{}
|
||||||
|
for k, v := range c.Request.PostForm {
|
||||||
|
if len(v) == 1 {
|
||||||
|
form[k] = v[0]
|
||||||
|
} else {
|
||||||
|
form[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["form"] = form
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipartEcho(c *gin.Context) {
|
||||||
|
_ = c.Request.ParseMultipartForm(8 << 20)
|
||||||
|
form := gin.H{}
|
||||||
|
if c.Request.MultipartForm != nil {
|
||||||
|
for k, v := range c.Request.MultipartForm.Value {
|
||||||
|
if len(v) == 1 {
|
||||||
|
form[k] = v[0]
|
||||||
|
} else {
|
||||||
|
form[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["form"] = form
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func headersEcho(c *gin.Context) {
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["xOpsxProbe"] = c.GetHeader("X-Opsx-Probe")
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookiesEcho(c *gin.Context) {
|
||||||
|
out := baseMeta(c)
|
||||||
|
cookies := gin.H{}
|
||||||
|
for _, ck := range c.Request.Cookies() {
|
||||||
|
cookies[ck.Name] = ck.Value
|
||||||
|
}
|
||||||
|
// 浏览器禁止脚本设置 Cookie 头;回归用例改走 X-Opsx-Cookie
|
||||||
|
if raw := c.GetHeader("X-Opsx-Cookie"); raw != "" {
|
||||||
|
for _, part := range strings.Split(raw, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kv := strings.SplitN(part, "=", 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["cookies"] = cookies
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerEcho(c *gin.Context) {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing bearer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["authenticated"] = true
|
||||||
|
out["token"] = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicEcho(c *gin.Context) {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Basic ") {
|
||||||
|
c.Header("WWW-Authenticate", `Basic realm="opsxapi"`)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing basic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(auth, "Basic ")))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "bad basic"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(raw), ":", 2)
|
||||||
|
out := baseMeta(c)
|
||||||
|
out["authenticated"] = true
|
||||||
|
out["user"] = parts[0]
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# OpsxAPI P0 手测脚本(10 步)
|
||||||
|
|
||||||
|
前置:`export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH"`,在仓库根目录 `./run.sh web`,打开 http://127.0.0.1:5173 。
|
||||||
|
|
||||||
|
1. 打开工作台,确认左上可切到「自动化测试」(或顶栏进入测试视图)。
|
||||||
|
2. 点 **装载回归示例**;环境应切到「本地」;出现用例「HTTP 八组合回归(本地 echo)」。
|
||||||
|
3. 点该用例右上 **运行**;8 步应全绿(需 :8787 已起)。失败则看报告里哪一步断言挂了。
|
||||||
|
4. 在报告区分别点 **导出 MD**、**导出 JSON**,确认文件可打开且 JSON 含 `schema: opsxapi.test-run.v1`。
|
||||||
|
5. 设置 → AI 助手:添加供应商(可用 Ollama),点测试连接(无 Key 的本地模型可过)。**未配置时可跳过 6。**
|
||||||
|
6. AI 生成:选「示例集合」,生成后点 **保存并运行**;报告触发方式应显示「AI」。
|
||||||
|
7. 对任一用例点定时图标:填 Cron `*/5 * * * *`(或短间隔),启用;保持页面打开。
|
||||||
|
8. 等到命中分钟后,报告出现 trigger=定时;定时对话框可见「日志 …」一行;控制台有 `[OpsxAPI 定时]`。
|
||||||
|
9. 手工再跑一遍八组合,确认与定时/AI 共用同一报告列表。
|
||||||
|
10. 刷新页面:用例与最近报告仍在(localStorage);`./run.sh status` 仍显示 server/web UP。
|
||||||
|
|
||||||
|
回滚演示数据:设置里如有「重置工作区」则用之;或清站点 localStorage。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 极简 5 段 Cron 匹配(分 时 日 月 周),本地 Date。
|
||||||
|
* 支持:*、数字、逗号列表、A-B 范围、* /n 步长。不支持 L W # 与英文月名。
|
||||||
|
* 自研实现,非抄竞品调度器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseField(field: string, min: number, max: number): Set<number> {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const part of field.split(",")) {
|
||||||
|
const stepMatch = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part.trim());
|
||||||
|
if (!stepMatch) continue;
|
||||||
|
const [, base, stepStr] = stepMatch;
|
||||||
|
const step = stepStr ? Number(stepStr) : 1;
|
||||||
|
let start = min;
|
||||||
|
let end = max;
|
||||||
|
if (base !== "*") {
|
||||||
|
if (base.includes("-")) {
|
||||||
|
const [a, b] = base.split("-").map(Number);
|
||||||
|
start = a;
|
||||||
|
end = b;
|
||||||
|
} else {
|
||||||
|
start = end = Number(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = start; i <= end; i += step) {
|
||||||
|
if (i >= min && i <= max) out.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验表达式粗格式 */
|
||||||
|
export function isValidCron(expr: string): boolean {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return false;
|
||||||
|
return parts.every((p) => /^[\d*,\-\/]+$/.test(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** date 是否命中 cron(精确到分钟) */
|
||||||
|
export function cronMatches(expr: string, date: Date = new Date()): boolean {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) return false;
|
||||||
|
const [minF, hourF, domF, monF, dowF] = parts;
|
||||||
|
const minute = date.getMinutes();
|
||||||
|
const hour = date.getHours();
|
||||||
|
const dom = date.getDate();
|
||||||
|
const mon = date.getMonth() + 1;
|
||||||
|
// JS: 0=Sun..6=Sat;Cron 常 0=Sun;也接受 7=Sun
|
||||||
|
let dow = date.getDay();
|
||||||
|
const mins = parseField(minF, 0, 59);
|
||||||
|
const hours = parseField(hourF, 0, 23);
|
||||||
|
const doms = parseField(domF, 1, 31);
|
||||||
|
const mons = parseField(monF, 1, 12);
|
||||||
|
const dows = parseField(dowF, 0, 7);
|
||||||
|
if (dows.has(7)) dows.add(0);
|
||||||
|
return mins.has(minute) && hours.has(hour) && doms.has(dom) && mons.has(mon) && dows.has(dow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 距 lastRunAt 是否应在「当前分钟」再跑:
|
||||||
|
* - 有 cron:表达式命中当前分钟,且上次不在同一分钟
|
||||||
|
* - 无 cron:沿用间隔分钟
|
||||||
|
*/
|
||||||
|
export function scheduleIsDue(
|
||||||
|
schedule: { intervalMinutes: number; cron?: string | null; lastRunAt: number | null },
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
const cron = schedule.cron?.trim();
|
||||||
|
if (cron) {
|
||||||
|
if (!isValidCron(cron) || !cronMatches(cron, now)) return false;
|
||||||
|
if (schedule.lastRunAt == null) return true;
|
||||||
|
const last = new Date(schedule.lastRunAt);
|
||||||
|
return !(
|
||||||
|
last.getFullYear() === now.getFullYear() &&
|
||||||
|
last.getMonth() === now.getMonth() &&
|
||||||
|
last.getDate() === now.getDate() &&
|
||||||
|
last.getHours() === now.getHours() &&
|
||||||
|
last.getMinutes() === now.getMinutes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const intervalMs = Math.max(1, schedule.intervalMinutes) * 60_000;
|
||||||
|
return schedule.lastRunAt === null || now.getTime() - schedule.lastRunAt >= intervalMs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createSeedWorkspace } from "./factory";
|
||||||
|
|
||||||
|
describe("createSeedWorkspace HTTP8 regression", () => {
|
||||||
|
it("seeds eight echo steps bound to real node ids", () => {
|
||||||
|
const ws = createSeedWorkspace();
|
||||||
|
const tc = ws.testCases.find((c) => c.id === "tc-opsx-http8-regression");
|
||||||
|
expect(tc).toBeTruthy();
|
||||||
|
expect(tc!.steps).toHaveLength(8);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const walk = (nodes: { type: string; id: string; children?: unknown[] }[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
ids.add(n.id);
|
||||||
|
if (n.type === "folder" && Array.isArray(n.children)) walk(n.children as typeof nodes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const c of ws.collections) walk(c.children as { type: string; id: string; children?: unknown[] }[]);
|
||||||
|
for (const s of tc!.steps) {
|
||||||
|
expect(ids.has(s.nodeId)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(ws.activeEnvironmentId).toBe(ws.environments.find((e) => e.name === "本地")?.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
SseRequestData,
|
SseRequestData,
|
||||||
WebsocketRequestData,
|
WebsocketRequestData,
|
||||||
WorkspaceData,
|
WorkspaceData,
|
||||||
|
TestCase,
|
||||||
|
TestStep,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { newId } from "./tree";
|
import { newId } from "./tree";
|
||||||
|
|
||||||
@@ -183,7 +185,80 @@ export function createSeedWorkspace(): WorkspaceData {
|
|||||||
description,
|
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("示例集合", [
|
const demo = createCollection("示例集合", [
|
||||||
|
createFolder("HTTP · 本地回归(P0)", [
|
||||||
|
echoGet,
|
||||||
|
echoJson,
|
||||||
|
echoForm,
|
||||||
|
echoMultipart,
|
||||||
|
echoHeaders,
|
||||||
|
echoCookies,
|
||||||
|
echoBearer,
|
||||||
|
echoBasic,
|
||||||
|
]),
|
||||||
createFolder("HTTP · httpbin", [
|
createFolder("HTTP · httpbin", [
|
||||||
http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"),
|
http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"),
|
||||||
http(
|
http(
|
||||||
@@ -253,19 +328,59 @@ export function createSeedWorkspace(): WorkspaceData {
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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 = [
|
const envs = [
|
||||||
createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"),
|
createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"),
|
||||||
createEnvironment("本地", [{ key: "token", value: "dev-token" }], "http://127.0.0.1:8787"),
|
createEnvironment("本地", [{ key: "token", value: "demo-token" }], "http://127.0.0.1:8787"),
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
collections: [demo],
|
collections: [demo],
|
||||||
environments: envs,
|
environments: envs,
|
||||||
activeEnvironmentId: envs[0]?.id ?? null,
|
activeEnvironmentId: envs[1]?.id ?? envs[0]?.id ?? null,
|
||||||
history: [],
|
history: [],
|
||||||
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
|
||||||
testCases: [],
|
testCases: [regressionCase],
|
||||||
testRuns: [],
|
testRuns: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export {
|
|||||||
suggestAssertions,
|
suggestAssertions,
|
||||||
extractVariables,
|
extractVariables,
|
||||||
exportRunMarkdown,
|
exportRunMarkdown,
|
||||||
|
exportRunJson,
|
||||||
newAssertion,
|
newAssertion,
|
||||||
newExtract,
|
newExtract,
|
||||||
newStep,
|
newStep,
|
||||||
@@ -163,3 +164,6 @@ export type { ExportFormat, OpenApiExportOptions } from "./exporters";
|
|||||||
export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl";
|
export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl";
|
||||||
export { generateCode } from "./exporters/codegen";
|
export { generateCode } from "./exporters/codegen";
|
||||||
export type { CodeTarget } from "./exporters/codegen";
|
export type { CodeTarget } from "./exporters/codegen";
|
||||||
|
|
||||||
|
// 定时 Cron
|
||||||
|
export { isValidCron, cronMatches, scheduleIsDue } from "./cron";
|
||||||
@@ -390,9 +390,16 @@ export type TestRun = {
|
|||||||
export type TestSchedule = {
|
export type TestSchedule = {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** 间隔分钟 */
|
/** 间隔分钟(未设置 cron 时使用) */
|
||||||
intervalMinutes: number;
|
intervalMinutes: number;
|
||||||
|
/**
|
||||||
|
* 可选 5 段 Cron(分 时 日 月 周),本地时区。
|
||||||
|
* 设置后优先于 intervalMinutes。例:每 5 分钟一次(分 位写 星号斜杠 5)
|
||||||
|
*/
|
||||||
|
cron?: string | null;
|
||||||
lastRunAt: number | null;
|
lastRunAt: number | null;
|
||||||
|
/** 最近一次调度日志(环形,最多保留若干条由调度器截断) */
|
||||||
|
runLog?: { at: number; ok: boolean; message: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------------- AI 助手 ---------------- */
|
/* ---------------- AI 助手 ---------------- */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
describeAssertion,
|
describeAssertion,
|
||||||
evaluateAssertion,
|
evaluateAssertion,
|
||||||
|
exportRunJson,
|
||||||
exportRunMarkdown,
|
exportRunMarkdown,
|
||||||
extractVariables,
|
extractVariables,
|
||||||
newTestCase,
|
newTestCase,
|
||||||
@@ -144,3 +145,48 @@ describe("用例构造", () => {
|
|||||||
expect(tc.id).toMatch(/^tc-/);
|
expect(tc.id).toMatch(/^tc-/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { cronMatches, isValidCron, scheduleIsDue } from "./cron";
|
||||||
|
|
||||||
|
describe("exportRunJson", () => {
|
||||||
|
it("emits opsxapi schema", () => {
|
||||||
|
const json = JSON.parse(
|
||||||
|
exportRunJson({
|
||||||
|
caseName: "demo",
|
||||||
|
at: 1,
|
||||||
|
trigger: "manual",
|
||||||
|
totalMs: 10,
|
||||||
|
passed: true,
|
||||||
|
stepResults: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(json.schema).toBe("opsxapi.test-run.v1");
|
||||||
|
expect(json.caseName).toBe("demo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cron", () => {
|
||||||
|
it("validates 5-field expressions", () => {
|
||||||
|
expect(isValidCron("*/5 * * * *")).toBe(true);
|
||||||
|
expect(isValidCron("* * *")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches */5 minutes", () => {
|
||||||
|
const d = new Date(2026, 0, 1, 12, 10, 0); // min=10
|
||||||
|
expect(cronMatches("*/5 * * * *", d)).toBe(true);
|
||||||
|
expect(cronMatches("*/5 * * * *", new Date(2026, 0, 1, 12, 11, 0))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scheduleIsDue prefers cron over interval", () => {
|
||||||
|
const d = new Date(2026, 0, 1, 12, 0, 0);
|
||||||
|
expect(
|
||||||
|
scheduleIsDue({ intervalMinutes: 999, cron: "0 * * * *", lastRunAt: null }, d),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
scheduleIsDue(
|
||||||
|
{ intervalMinutes: 1, cron: "0 * * * *", lastRunAt: d.getTime() },
|
||||||
|
d,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -255,6 +255,45 @@ export function exportRunMarkdown(run: {
|
|||||||
|
|
||||||
/* ---------------- 用例构造 ---------------- */
|
/* ---------------- 用例构造 ---------------- */
|
||||||
|
|
||||||
|
|
||||||
|
/** JSON 报告(便于 CI / 二次处理) */
|
||||||
|
export function exportRunJson(run: {
|
||||||
|
id?: string;
|
||||||
|
caseId?: string;
|
||||||
|
caseName: string;
|
||||||
|
at: number;
|
||||||
|
trigger: string;
|
||||||
|
totalMs: number;
|
||||||
|
passed: boolean;
|
||||||
|
stepResults: {
|
||||||
|
name: string;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
ok: boolean;
|
||||||
|
httpStatus: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
assertionResults: AssertionResult[];
|
||||||
|
}[];
|
||||||
|
}): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
schema: "opsxapi.test-run.v1",
|
||||||
|
id: run.id ?? null,
|
||||||
|
caseId: run.caseId ?? null,
|
||||||
|
caseName: run.caseName,
|
||||||
|
at: run.at,
|
||||||
|
trigger: run.trigger,
|
||||||
|
totalMs: run.totalMs,
|
||||||
|
passed: run.passed,
|
||||||
|
stats: runStats(run),
|
||||||
|
stepResults: run.stepResults,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function newAssertion(): TestAssertion {
|
export function newAssertion(): TestAssertion {
|
||||||
return {
|
return {
|
||||||
id: `as-${Math.random().toString(36).slice(2, 10)}`,
|
id: `as-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type {
|
|||||||
} from "@opsxapi/core";
|
} from "@opsxapi/core";
|
||||||
import {
|
import {
|
||||||
ASSERT_OP_LABELS,
|
ASSERT_OP_LABELS,
|
||||||
|
createSeedWorkspace,
|
||||||
|
exportRunJson,
|
||||||
exportRunMarkdown,
|
exportRunMarkdown,
|
||||||
|
isValidCron,
|
||||||
newAssertion,
|
newAssertion,
|
||||||
newExtract,
|
newExtract,
|
||||||
newStep,
|
newStep,
|
||||||
@@ -54,6 +57,38 @@ function useHttpNodeOptions(): { id: string; label: string; method: string }[] {
|
|||||||
}, [collections]);
|
}, [collections]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const REGRESSION_CASE_ID = "tc-opsx-http8-regression";
|
||||||
|
|
||||||
|
/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */
|
||||||
|
function installHttp8Regression(): string {
|
||||||
|
const seed = createSeedWorkspace();
|
||||||
|
const state = useWorkspace.getState();
|
||||||
|
const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!;
|
||||||
|
const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0];
|
||||||
|
const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID);
|
||||||
|
const hasCollection = state.collections.some((c) => c.name === "示例集合");
|
||||||
|
const localExisting = state.environments.find((e) => e.name === "本地");
|
||||||
|
state.loadWorkspaceData({
|
||||||
|
version: 2,
|
||||||
|
collections: hasCollection
|
||||||
|
? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c))
|
||||||
|
: [...state.collections, ...seed.collections],
|
||||||
|
environments: localExisting
|
||||||
|
? state.environments
|
||||||
|
: [...state.environments, ...seed.environments.filter((e) => e.name === "本地")],
|
||||||
|
activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId,
|
||||||
|
history: state.history,
|
||||||
|
settings: state.settings,
|
||||||
|
testCases: hasCase
|
||||||
|
? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c))
|
||||||
|
: [...state.testCases, seedCase],
|
||||||
|
testRuns: state.testRuns,
|
||||||
|
schedules: state.schedules,
|
||||||
|
});
|
||||||
|
return seedCase.id;
|
||||||
|
}
|
||||||
|
|
||||||
export function TestingView() {
|
export function TestingView() {
|
||||||
const testCases = useWorkspace((s) => s.testCases);
|
const testCases = useWorkspace((s) => s.testCases);
|
||||||
const testRuns = useWorkspace((s) => s.testRuns);
|
const testRuns = useWorkspace((s) => s.testRuns);
|
||||||
@@ -96,6 +131,13 @@ export function TestingView() {
|
|||||||
<Icon name="bot" size={13} /> AI 生成
|
<Icon name="bot" size={13} /> AI 生成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-secondary mt-1.5 w-full !px-2"
|
||||||
|
title="装载本地 Gin /v1/echo 八组合回归用例"
|
||||||
|
onClick={() => setSelected(installHttp8Regression())}
|
||||||
|
>
|
||||||
|
<Icon name="play" size={13} /> 装载回归示例
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops-scroll min-h-0 flex-1 p-2">
|
<div className="ops-scroll min-h-0 flex-1 p-2">
|
||||||
{testCases.length === 0 && (
|
{testCases.length === 0 && (
|
||||||
@@ -498,13 +540,28 @@ function ScheduleInline({ caseId }: { caseId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const cron = sc.cron ?? "";
|
||||||
|
const cronOk = !cron.trim() || isValidCron(cron.trim());
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
|
||||||
|
placeholder="Cron 可选 */5 * * * *"
|
||||||
|
value={cron}
|
||||||
|
title={cronOk ? "5 段 Cron(分 时 日 月 周);留空则用间隔" : "Cron 格式无效"}
|
||||||
|
onChange={(e) => upsertSchedule(caseId, { cron: e.target.value.trim() || null })}
|
||||||
|
style={!cronOk ? { borderColor: "var(--danger)" } : undefined}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
className="ops-input ops-input-sm !w-24"
|
className="ops-input ops-input-sm !w-24"
|
||||||
value={sc.intervalMinutes}
|
value={sc.intervalMinutes}
|
||||||
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value) })}
|
disabled={Boolean(cron.trim())}
|
||||||
title={`下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")}`}
|
onChange={(e) => upsertSchedule(caseId, { intervalMinutes: Number(e.target.value), cron: null })}
|
||||||
|
title={
|
||||||
|
cron.trim()
|
||||||
|
? "已启用 Cron,间隔停用"
|
||||||
|
: `下次运行:${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{[5, 15, 30, 60, 360, 1440].map((m) => (
|
{[5, 15, 30, 60, 360, 1440].map((m) => (
|
||||||
<option key={m} value={m}>
|
<option key={m} value={m}>
|
||||||
@@ -554,13 +611,16 @@ function ReportPanel({ caseId }: { caseId: string }) {
|
|||||||
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
|
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
|
||||||
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
|
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
|
||||||
|
|
||||||
const download = () => {
|
const download = (format: "md" | "json") => {
|
||||||
const md = exportRunMarkdown(run);
|
const body = format === "json" ? exportRunJson(run) : exportRunMarkdown(run);
|
||||||
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
const blob = new Blob([body], {
|
||||||
|
type: format === "json" ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${run.caseName}-报告-${new Date(run.at).toISOString().slice(0, 10)}.md`;
|
const day = new Date(run.at).toISOString().slice(0, 10);
|
||||||
|
a.download = `${run.caseName}-报告-${day}.${format === "json" ? "json" : "md"}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
@@ -596,8 +656,11 @@ function ReportPanel({ caseId }: { caseId: string }) {
|
|||||||
>
|
>
|
||||||
<Icon name="rotate-cw" size={13} /> 再次运行
|
<Icon name="rotate-cw" size={13} /> 再次运行
|
||||||
</button>
|
</button>
|
||||||
<button className="ops-btn ops-btn-secondary" onClick={download}>
|
<button className="ops-btn ops-btn-secondary" onClick={() => download("md")}>
|
||||||
<Icon name="file-down" size={13} /> 导出报告
|
<Icon name="file-down" size={13} /> 导出 MD
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-secondary" onClick={() => download("json")}>
|
||||||
|
<Icon name="file-down" size={13} /> 导出 JSON
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { AiCaseDraft } from "../../ai";
|
import type { AiCaseDraft } from "../../ai";
|
||||||
import { aiGenerateTestCase } from "../../ai";
|
import { aiGenerateTestCase } from "../../ai";
|
||||||
|
import { runTestCase } from "../../testRunner";
|
||||||
import { useWorkspace } from "../../workspaceStore";
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
import { Modal } from "../common";
|
import { Modal } from "../common";
|
||||||
import { Icon } from "../icons";
|
import { Icon } from "../icons";
|
||||||
@@ -39,14 +40,35 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const persistDraft = (): string | null => {
|
||||||
if (!draft) return;
|
if (!draft) return null;
|
||||||
const id = addTestCase();
|
const id = addTestCase();
|
||||||
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
|
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const id = persistDraft();
|
||||||
|
if (!id) return;
|
||||||
onSaved(id);
|
onSaved(id);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveAndRun = async () => {
|
||||||
|
const id = persistDraft();
|
||||||
|
if (!id) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await runTestCase(id, "ai");
|
||||||
|
onSaved(id);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="AI 生成场景用例"
|
title="AI 生成场景用例"
|
||||||
@@ -60,9 +82,14 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<button className="ops-btn ops-btn-primary" onClick={save}>
|
<>
|
||||||
<Icon name="circle-check" size={13} /> 保存用例
|
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={save}>
|
||||||
</button>
|
<Icon name="circle-check" size={13} /> 仅保存
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={saveAndRun}>
|
||||||
|
<Icon name="play" size={13} /> {busy ? "运行中…" : "保存并运行"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="ops-btn ops-btn-primary"
|
className="ops-btn ops-btn-primary"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
|
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
|
||||||
*/
|
*/
|
||||||
|
import { isValidCron } from "@opsxapi/core";
|
||||||
import { nextRunAt } from "../../scheduler";
|
import { nextRunAt } from "../../scheduler";
|
||||||
import { useWorkspace } from "../../workspaceStore";
|
import { useWorkspace } from "../../workspaceStore";
|
||||||
import { Modal, cx } from "../common";
|
import { Modal, cx } from "../common";
|
||||||
@@ -30,7 +31,7 @@ export function ScheduleDialog({ onClose }: { onClose: () => void }) {
|
|||||||
width="640px"
|
width="640px"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<span className="ops-faint text-[11px]">应用保持运行时按间隔自动执行并记录报告;关闭应用后暂停</span>
|
<span className="ops-faint text-[11px]">应用保持运行时按 Cron 或间隔自动执行;写入运行报告与调度日志;关闭应用后暂停</span>
|
||||||
<button className="ops-btn ops-btn-primary" onClick={onClose}>
|
<button className="ops-btn ops-btn-primary" onClick={onClose}>
|
||||||
完成
|
完成
|
||||||
</button>
|
</button>
|
||||||
@@ -50,17 +51,30 @@ export function ScheduleDialog({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="truncate text-[13px] font-medium">{tc.name}</div>
|
<div className="truncate text-[13px] font-medium">{tc.name}</div>
|
||||||
<div className="ops-faint text-[11px]">
|
<div className="ops-faint text-[11px]">
|
||||||
{sc?.enabled
|
{sc?.enabled
|
||||||
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt)).toLocaleTimeString("zh-CN")} 运行`
|
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")} 运行`
|
||||||
: "未启用定时"}
|
: "未启用定时"}
|
||||||
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
|
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
|
||||||
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
|
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
|
||||||
|
{sc?.runLog?.[0] ? ` · 日志 ${sc.runLog[0].message}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
|
||||||
|
placeholder="Cron */5 * * * *"
|
||||||
|
disabled={!sc}
|
||||||
|
value={sc?.cron ?? ""}
|
||||||
|
title="5 段 Cron;填写后优先于间隔"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
upsertSchedule(tc.id, { cron: v || null });
|
||||||
|
}}
|
||||||
|
style={sc?.cron && !isValidCron(sc.cron) ? { borderColor: "var(--danger)" } : undefined}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
className="ops-input ops-input-sm !w-28"
|
className="ops-input ops-input-sm !w-28"
|
||||||
value={sc?.intervalMinutes ?? 30}
|
value={sc?.intervalMinutes ?? 30}
|
||||||
disabled={!sc}
|
disabled={!sc || Boolean(sc?.cron?.trim())}
|
||||||
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value) })}
|
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value), cron: null })}
|
||||||
>
|
>
|
||||||
{INTERVALS.map((m) => (
|
{INTERVALS.map((m) => (
|
||||||
<option key={m} value={m}>
|
<option key={m} value={m}>
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* 定时任务调度器:应用运行期间每 30s 巡检一次,
|
* 定时任务调度器:应用运行期间每 15s 巡检一次。
|
||||||
* 对启用且到达间隔的用例自动执行并记录报告(trigger = schedule)。
|
* - 有 cron:按 5 段表达式(本地时区)命中当前分钟则跑
|
||||||
|
* - 无 cron:按 intervalMinutes
|
||||||
|
* 执行走同一 runTestCase(trigger = schedule),并写入 schedule.runLog。
|
||||||
*/
|
*/
|
||||||
|
import { scheduleIsDue } from "@opsxapi/core";
|
||||||
import { runTestCase } from "./testRunner";
|
import { runTestCase } from "./testRunner";
|
||||||
import { useWorkspace } from "./workspaceStore";
|
import { useWorkspace } from "./workspaceStore";
|
||||||
|
|
||||||
const TICK_MS = 30_000;
|
const TICK_MS = 15_000;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let running = new Set<string>();
|
let running = new Set<string>();
|
||||||
|
|
||||||
function isDue(intervalMinutes: number, lastRunAt: number | null): boolean {
|
function appendLog(caseId: string, ok: boolean, message: string): void {
|
||||||
const intervalMs = Math.max(1, intervalMinutes) * 60_000;
|
const { schedules, upsertSchedule } = useWorkspace.getState();
|
||||||
return lastRunAt === null || Date.now() - lastRunAt >= intervalMs;
|
const sc = schedules.find((s) => s.caseId === caseId);
|
||||||
|
if (!sc) return;
|
||||||
|
const entry = { at: Date.now(), ok, message };
|
||||||
|
const runLog = [entry, ...(sc.runLog ?? [])].slice(0, 30);
|
||||||
|
upsertSchedule(caseId, { runLog });
|
||||||
|
const line = `[OpsxAPI 定时] ${new Date(entry.at).toLocaleString("zh-CN")} case=${caseId} ${ok ? "OK" : "FAIL"} ${message}`;
|
||||||
|
if (ok) console.info(line);
|
||||||
|
else console.warn(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tick(): void {
|
function tick(): void {
|
||||||
|
const now = new Date();
|
||||||
const { schedules } = useWorkspace.getState();
|
const { schedules } = useWorkspace.getState();
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
if (!s.enabled) continue;
|
if (!s.enabled) continue;
|
||||||
if (!isDue(s.intervalMinutes, s.lastRunAt)) continue;
|
if (!scheduleIsDue(s, now)) continue;
|
||||||
if (running.has(s.caseId)) continue;
|
if (running.has(s.caseId)) continue;
|
||||||
running = new Set([...running, s.caseId]);
|
running = new Set([...running, s.caseId]);
|
||||||
|
const label = s.cron?.trim() ? `cron ${s.cron}` : `every ${s.intervalMinutes}m`;
|
||||||
void runTestCase(s.caseId, "schedule")
|
void runTestCase(s.caseId, "schedule")
|
||||||
.catch(() => undefined)
|
.then((run) => {
|
||||||
|
if (!run) {
|
||||||
|
appendLog(s.caseId, false, `${label} · 用例不存在`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendLog(s.caseId, run.passed, `${label} · ${run.passed ? "通过" : "失败"} · ${run.totalMs}ms`);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
appendLog(s.caseId, false, `${label} · ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
running = new Set([...running].filter((id) => id !== s.caseId));
|
running = new Set([...running].filter((id) => id !== s.caseId));
|
||||||
});
|
});
|
||||||
@@ -32,6 +53,8 @@ function tick(): void {
|
|||||||
export function startScheduler(): void {
|
export function startScheduler(): void {
|
||||||
if (timer) return;
|
if (timer) return;
|
||||||
timer = setInterval(tick, TICK_MS);
|
timer = setInterval(tick, TICK_MS);
|
||||||
|
// 启动后立刻巡检一次,避免干等
|
||||||
|
tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopScheduler(): void {
|
export function stopScheduler(): void {
|
||||||
@@ -39,8 +62,12 @@ export function stopScheduler(): void {
|
|||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 供 UI 显示:下次预计运行时间(ms 时间戳) */
|
/** 供 UI 显示:下次预计运行时间(仅 interval 模式有意义;cron 返回下一分钟近似) */
|
||||||
export function nextRunAt(intervalMinutes: number, lastRunAt: number | null): number {
|
export function nextRunAt(intervalMinutes: number, lastRunAt: number | null, cron?: string | null): number {
|
||||||
|
if (cron?.trim()) {
|
||||||
|
// 粗略:下一分钟整点再由表达式过滤;UI 仅作提示
|
||||||
|
return Date.now() + 60_000;
|
||||||
|
}
|
||||||
const intervalMs = Math.max(1, intervalMinutes) * 60_000;
|
const intervalMs = Math.max(1, intervalMinutes) * 60_000;
|
||||||
return (lastRunAt ?? Date.now() - intervalMs) + intervalMs;
|
return (lastRunAt ?? Date.now() - intervalMs) + intervalMs;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user