fix(autopilots): parse compound cron fields as custom in trigger editor (#5302)

Closes #5301
This commit is contained in:
YYClaw
2026-07-14 02:08:43 +08:00
committed by GitHub
parent ace0f16cad
commit 39474ee3aa
2 changed files with 240 additions and 48 deletions

View File

@@ -3,66 +3,248 @@ import {
parseCronExpression,
toCronExpression,
getDefaultTriggerConfig,
type TriggerConfig,
} from "./trigger-config";
// The backend accepts standard 5-field cron (robfig/cron v3: minute, hour,
// dom, month, dow — no seconds, no @descriptors). Each field may be `*`, a
// plain number, a range `a-b`, a step `*/n` | `a/n` | `a-b/n`, a list, or a
// name (JAN/MON). The form only models four exact shapes:
//
// hourly M * * * *
// daily M H * * *
// weekdays M H * * 1-5
// weekly M H * * d[,d...] (plain days 0-6)
//
// where M/H/d are plain in-range integers. Every other expression must parse
// as `custom` with the original string preserved in cronExpression.
describe("parseCronExpression", () => {
it("round-trips hourly", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "hourly" as const, time: "00:15" };
const cron = toCronExpression(cfg);
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("hourly");
describe("preset recognition", () => {
it("recognises hourly at minute 0", () => {
const parsed = parseCronExpression("0 * * * *", "UTC");
expect(parsed.frequency).toBe("hourly");
expect(parsed.time).toBe("00:00");
});
it("recognises hourly at minute 30", () => {
const parsed = parseCronExpression("30 * * * *", "UTC");
expect(parsed.frequency).toBe("hourly");
expect(parsed.time).toBe("00:30");
});
it("recognises hourly with a zero-padded minute", () => {
const parsed = parseCronExpression("05 * * * *", "UTC");
expect(parsed.frequency).toBe("hourly");
expect(parsed.time).toBe("00:05");
});
it("recognises daily at midnight", () => {
const parsed = parseCronExpression("0 0 * * *", "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("00:00");
});
it("recognises daily at the last minute of the day", () => {
const parsed = parseCronExpression("59 23 * * *", "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("23:59");
});
it("recognises daily with zero-padded fields", () => {
const parsed = parseCronExpression("05 09 * * *", "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("09:05");
});
it("recognises the weekdays pattern", () => {
const parsed = parseCronExpression("30 18 * * 1-5", "UTC");
expect(parsed.frequency).toBe("weekdays");
expect(parsed.time).toBe("18:30");
expect(parsed.daysOfWeek).toEqual([1, 2, 3, 4, 5]);
});
it("recognises weekly on a single day", () => {
const parsed = parseCronExpression("0 9 * * 0", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([0]);
});
it("recognises weekly on Saturday (upper bound)", () => {
const parsed = parseCronExpression("0 9 * * 6", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([6]);
});
it("recognises weekly with multiple days", () => {
const parsed = parseCronExpression("0 9 * * 1,3,5", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([1, 3, 5]);
expect(parsed.time).toBe("09:00");
});
it("normalises unsorted, duplicated weekly days", () => {
const parsed = parseCronExpression("0 9 * * 5,1,5", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([1, 5]);
});
it("tolerates surrounding and repeated whitespace", () => {
const parsed = parseCronExpression(" 0 9 * * * ", "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("09:00");
});
it("tolerates tab separators", () => {
const parsed = parseCronExpression("0\t9\t*\t*\t*", "UTC");
expect(parsed.frequency).toBe("daily");
});
});
it("round-trips daily at 09:30", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "daily" as const, time: "09:30" };
const cron = toCronExpression(cfg);
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("daily");
expect(parsed.time).toBe("09:30");
describe("custom fallback — minute field", () => {
it.each([
["range", "0-30 9 * * *"],
["list", "0,30 9 * * *"],
["step over wildcard", "*/15 9 * * *"],
["step from anchor", "0/5 9 * * *"],
["step over range", "0-30/5 9 * * *"],
["wildcard (every minute of the hour)", "* 9 * * *"],
["wildcard everywhere (every minute)", "* * * * *"],
["out of range", "60 9 * * *"],
["out of range with wildcard hour", "60 * * * *"],
["negative", "-5 9 * * *"],
])("falls back to custom for minute %s", (_label, cron) => {
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe(cron);
});
});
it("recognises weekdays pattern", () => {
const parsed = parseCronExpression("0 9 * * 1-5", "UTC");
expect(parsed.frequency).toBe("weekdays");
expect(parsed.time).toBe("09:00");
describe("custom fallback — hour field", () => {
it.each([
["range", "0 9-21 * * *"],
["list", "0 9,12,15 * * *"],
["step over wildcard", "0 */2 * * *"],
["step from anchor", "0 9/2 * * *"],
["step over range", "0 9-17/2 * * *"],
["out of range", "0 24 * * *"],
["range with weekday dow", "0 9-21 * * 1-5"],
["range with listed dow", "0 9-21 * * 1,3"],
])("falls back to custom for hour %s", (_label, cron) => {
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe(cron);
});
});
it("recognises weekly with multiple days", () => {
const parsed = parseCronExpression("0 9 * * 1,3,5", "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.daysOfWeek).toEqual([1, 3, 5]);
expect(parsed.time).toBe("09:00");
describe("custom fallback — day-of-month and month fields", () => {
it.each([
["specific day of month", "0 9 1 * *"],
["day-of-month range", "0 9 1-15 * *"],
["day-of-month step", "0 9 */2 * *"],
["question mark day of month", "0 9 ? * *"],
["specific month", "0 9 * 6 *"],
["month range", "0 9 * 1-6 *"],
["month name", "0 9 * JAN *"],
["fully pinned date", "15 3 15 6 2"],
])("falls back to custom for %s", (_label, cron) => {
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe(cron);
});
});
it("falls back to custom for non-matching pattern", () => {
const parsed = parseCronExpression("*/15 * * * *", "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe("*/15 * * * *");
describe("custom fallback — day-of-week field", () => {
it.each([
["range other than 1-5", "0 9 * * 2-4"],
["full-week range", "0 9 * * 0-6"],
["day name", "0 9 * * MON"],
["day name list", "0 9 * * MON,WED"],
["step", "0 9 * * */2"],
["step over range", "0 9 * * 1-5/2"],
["mixed list and range", "0 9 * * 1,3-5"],
["out-of-range day 7", "0 9 * * 7"],
["question mark", "0 9 * * ?"],
["hourly minute with weekday dow", "0 * * * 1-5"],
["hourly minute with single dow", "30 * * * 1"],
])("falls back to custom for dow %s", (_label, cron) => {
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe(cron);
});
});
it("falls back to custom for malformed input", () => {
const parsed = parseCronExpression("not a cron", "UTC");
expect(parsed.frequency).toBe("custom");
describe("custom fallback malformed structure", () => {
it.each([
["too few fields", "0 9 * *"],
["too many fields (seconds cron)", "0 0 9 * * *"],
["free text", "not a cron"],
["empty string", ""],
["whitespace only", " "],
["@daily descriptor", "@daily"],
["@every descriptor", "@every 1h"],
])("falls back to custom for %s", (_label, cron) => {
const parsed = parseCronExpression(cron, "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe(cron);
});
});
it("preserves provided timezone", () => {
const parsed = parseCronExpression("0 9 * * *", "Asia/Shanghai");
expect(parsed.timezone).toBe("Asia/Shanghai");
describe("invariants", () => {
it("preserves the raw expression on preset matches too", () => {
expect(parseCronExpression("30 18 * * 1-5", "UTC").cronExpression).toBe(
"30 18 * * 1-5",
);
});
it("preserves provided timezone", () => {
expect(parseCronExpression("0 9 * * *", "Asia/Shanghai").timezone).toBe(
"Asia/Shanghai",
);
expect(parseCronExpression("0 9-21 * * *", "Asia/Shanghai").timezone).toBe(
"Asia/Shanghai",
);
});
});
it("rejects out-of-range minute", () => {
expect(parseCronExpression("60 * * * *", "UTC").frequency).toBe("custom");
});
describe("round-trips through toCronExpression", () => {
const presets: Array<[string, Partial<TriggerConfig>]> = [
["hourly", { frequency: "hourly", time: "00:15" }],
["daily", { frequency: "daily", time: "09:30" }],
["weekdays", { frequency: "weekdays", time: "08:00" }],
["weekly", { frequency: "weekly", time: "14:45", daysOfWeek: [0, 2, 6] }],
];
it("rejects out-of-range hour", () => {
expect(parseCronExpression("0 24 * * *", "UTC").frequency).toBe("custom");
});
it.each(presets)("round-trips %s", (_label, overrides) => {
const cfg = { ...getDefaultTriggerConfig(), ...overrides };
const parsed = parseCronExpression(toCronExpression(cfg), "UTC");
expect(parsed.frequency).toBe(cfg.frequency);
expect(parsed.time).toBe(cfg.time);
if (cfg.frequency === "weekly") {
expect(parsed.daysOfWeek).toEqual(cfg.daysOfWeek);
}
});
it("round-trips weekly preserving daysOfWeek", () => {
const cfg = { ...getDefaultTriggerConfig(), frequency: "weekly" as const, time: "14:45", daysOfWeek: [0, 2, 6] };
const parsed = parseCronExpression(toCronExpression(cfg), "UTC");
expect(parsed.frequency).toBe("weekly");
expect(parsed.time).toBe("14:45");
expect(parsed.daysOfWeek).toEqual([0, 2, 6]);
it("serialises weekly with no days selected to Monday as a safety fallback", () => {
const cfg = {
...getDefaultTriggerConfig(),
frequency: "weekly" as const,
time: "09:00",
daysOfWeek: [],
};
expect(toCronExpression(cfg)).toBe("0 9 * * 1");
});
it("round-trips a custom expression verbatim", () => {
const cfg = {
...getDefaultTriggerConfig(),
frequency: "custom" as const,
cronExpression: "0 9-21 * * *",
};
const parsed = parseCronExpression(toCronExpression(cfg), "UTC");
expect(parsed.frequency).toBe("custom");
expect(parsed.cronExpression).toBe("0 9-21 * * *");
expect(toCronExpression(parsed)).toBe("0 9-21 * * *");
});
});
});

View File

@@ -134,6 +134,15 @@ export function toCronExpression(cfg: TriggerConfig): string {
}
}
// Strict field matcher: cron fields also allow ranges, lists, and steps
// ("9-21", "0,30", "*/5") that parseInt would silently truncate to their
// first number, so only bare 1-2 digit integers count as a preset match.
function parsePlainInt(s: string, max: number): number | null {
if (!/^\d{1,2}$/.test(s)) return null;
const n = parseInt(s, 10);
return n > max ? null : n;
}
export function parseCronExpression(cron: string, timezone: string): TriggerConfig {
const base: TriggerConfig = {
...getDefaultTriggerConfig(),
@@ -148,21 +157,22 @@ export function parseCronExpression(cron: string, timezone: string): TriggerConf
const mon = parts[3] ?? "";
const dow = parts[4] ?? "";
if (dom !== "*" || mon !== "*") return { ...base, frequency: "custom" };
const min = parseInt(minStr, 10);
if (Number.isNaN(min) || min < 0 || min > 59) return { ...base, frequency: "custom" };
const min = parsePlainInt(minStr, 59);
if (min === null) return { ...base, frequency: "custom" };
if (hourStr === "*" && dow === "*") {
if (hourStr === "*") {
if (dow !== "*") return { ...base, frequency: "custom" };
const time = `00:${String(min).padStart(2, "0")}`;
return { ...base, frequency: "hourly", time };
}
const hour = parseInt(hourStr, 10);
if (Number.isNaN(hour) || hour < 0 || hour > 23) return { ...base, frequency: "custom" };
const hour = parsePlainInt(hourStr, 23);
if (hour === null) return { ...base, frequency: "custom" };
const time = `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
if (dow === "*") return { ...base, frequency: "daily", time };
if (dow === "1-5") return { ...base, frequency: "weekdays", time, daysOfWeek: [1, 2, 3, 4, 5] };
if (/^[0-6](,[0-6])*$/.test(dow)) {
const days = dow.split(",").map((n) => parseInt(n, 10));
const days = sortedDays(dow.split(",").map((n) => parseInt(n, 10)));
return { ...base, frequency: "weekly", time, daysOfWeek: days };
}
return { ...base, frequency: "custom" };