定时任务 Cron 表达式支持(工作区遗留改动收尾)

- TestSchedule 新增可选 5 段 cron(分 时 日 月 周,本地时区),优先于 intervalMinutes;runLog 调度日志
- cron.ts 解析与匹配引擎 + 单测;scheduler 按最近下次触发时间调度
- ScheduleDialog / TestingView 支持 cron 输入与下次运行提示;AiCaseDialog 微调
- 服务端 internal/echo 调试回显端点;修复 model.ts 注释内 */ 导致的语法错误
- 全量 53 前端用例绿
This commit is contained in:
logan committed 2026-09-18 11:42:34 +08:00
1 parent ce6d2d1eff
commit 045e8c65c3
14 files changed
+657 -32

No files matched your search

+83
View File
@@ -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);
});
});
+118 -3
View File
@@ -19,6 +19,8 @@ import type {
SseRequestData,
WebsocketRequestData,
WorkspaceData,
TestCase,
TestStep,
} from "./model";
import { newId } from "./tree";
@@ -183,7 +185,80 @@ export function createSeedWorkspace(): WorkspaceData {
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(
@@ -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 = [
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 {
version: 2,
collections: [demo],
environments: envs,
activeEnvironmentId: envs[0]?.id ?? null,
activeEnvironmentId: envs[1]?.id ?? envs[0]?.id ?? null,
history: [],
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
testCases: [],
testCases: [regressionCase],
testRuns: [],
schedules: [],
};
+4
View File
@@ -125,6 +125,7 @@ export {
suggestAssertions,
extractVariables,
exportRunMarkdown,
exportRunJson,
newAssertion,
newExtract,
newStep,
@@ -163,3 +164,6 @@ export type { ExportFormat, OpenApiExportOptions } from "./exporters";
export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl";
export { generateCode } from "./exporters/codegen";
export type { CodeTarget } from "./exporters/codegen";
// 定时 Cron
export { isValidCron, cronMatches, scheduleIsDue } from "./cron";
+8 -1
View File
@@ -390,9 +390,16 @@ export type TestRun = {
export type TestSchedule = {
caseId: string;
enabled: boolean;
/** 间隔分钟 */
/** 间隔分钟(未设置 cron 时使用) */
intervalMinutes: number;
/**
* 可选 5 段 Cron(分 时 日 月 周),本地时区。
* 设置后优先于 intervalMinutes。例:每 5 分钟一次(分 位写 星号斜杠 5)
*/
cron?: string | null;
lastRunAt: number | null;
/** 最近一次调度日志(环形,最多保留若干条由调度器截断) */
runLog?: { at: number; ok: boolean; message: string }[];
};
/* ---------------- AI 助手 ---------------- */
+46
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
describeAssertion,
evaluateAssertion,
exportRunJson,
exportRunMarkdown,
extractVariables,
newTestCase,
@@ -144,3 +145,48 @@ describe("用例构造", () => {
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);
});
});
+39
View File
@@ -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 {
return {
id: `as-${Math.random().toString(36).slice(2, 10)}`,