mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-17 10:06:18 +00:00
* feat(scheduler): add interval schedule type Allow scheduled tasks to fire every N seconds from last dispatch, not only wall-clock cron or a single run_at. Cadence is UTC now+N with no missed-beat catch-up, bounded by min_once_delay_seconds and 30 days. * fix(scheduler): let interval tasks create, edit, and keep next run Create/edit now keep every_seconds. Unchanged interval spec no longer resets next_run_at, including timezone-only PATCH. * fix(scheduler): keep non-minute intervals on edit Stop rounding every_seconds to whole minutes in the form. Values that are not whole minutes or hours now use a seconds unit so edit/duplicate round-trips the stored cadence instead of rewriting it and resetting next_run_at. Document that min_once_delay_seconds is also the interval floor. * fix(scheduler): clamp interval seconds to the default 60s floor The new seconds unit allowed 1–59, which the API rejects under the default min_once_delay_seconds. Clamp the form to >= 60 and show the floor next to the preview. Also mention interval in the scheduler field_doc, matching config.example.yaml. * fix(scheduler): do not clamp interval amount while typing Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could not leave 60. Keep the raw field text and apply the 60s floor on blur and emit only. * test(scheduler): cover interval input editing * fix(frontend): preserve saved interval cadence until edited * style(tests): format scheduled task router tests --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
461 lines
12 KiB
TypeScript
461 lines
12 KiB
TypeScript
import { describe, expect, test } from "@rstest/core";
|
|
|
|
import {
|
|
describeSchedule,
|
|
hasScheduleSpec,
|
|
clampIntervalAmount,
|
|
intervalToSeconds,
|
|
minIntervalAmount,
|
|
parseCron,
|
|
secondsToInterval,
|
|
serializeCron,
|
|
utcToZonedLocalInput,
|
|
zonedLocalToUtcIso,
|
|
type CronParts,
|
|
} from "@/core/scheduled-tasks/cron";
|
|
|
|
describe("serializeCron", () => {
|
|
test("hourly emits minute + star fields", () => {
|
|
expect(serializeCron("hourly", { minute: 30 } as CronParts)).toBe(
|
|
"30 * * * *",
|
|
);
|
|
});
|
|
|
|
test("daily emits minute + hour", () => {
|
|
expect(serializeCron("daily", { minute: 0, hour: 9 } as CronParts)).toBe(
|
|
"0 9 * * *",
|
|
);
|
|
});
|
|
|
|
test("weekly emits comma-joined weekday numbers in cron order (0=sun)", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["mon", "wed"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 1,3");
|
|
});
|
|
|
|
test("weekly sorts + dedupes out-of-order / duplicate weekdays", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["wed", "mon", "wed"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 1,3");
|
|
});
|
|
|
|
test("weekly maps sunday to 0", () => {
|
|
expect(
|
|
serializeCron("weekly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: ["sun"],
|
|
} as CronParts),
|
|
).toBe("0 9 * * 0");
|
|
});
|
|
|
|
test("monthly emits day-of-month", () => {
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 1,
|
|
} as CronParts),
|
|
).toBe("0 9 1 * *");
|
|
});
|
|
|
|
test("custom returns raw expression", () => {
|
|
expect(serializeCron("custom", { raw: "*/5 * * * *" } as CronParts)).toBe(
|
|
"*/5 * * * *",
|
|
);
|
|
});
|
|
|
|
test("clamps out-of-range minute / hour / day-of-month", () => {
|
|
expect(serializeCron("daily", { minute: 99, hour: 24 } as CronParts)).toBe(
|
|
"59 23 * * *",
|
|
);
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 32,
|
|
} as CronParts),
|
|
).toBe("0 9 31 * *");
|
|
expect(
|
|
serializeCron("monthly", {
|
|
minute: 0,
|
|
hour: 9,
|
|
dayOfMonth: 0,
|
|
} as CronParts),
|
|
).toBe("0 9 1 * *");
|
|
});
|
|
});
|
|
|
|
describe("parseCron", () => {
|
|
test("hourly: M * * * *", () => {
|
|
expect(parseCron("30 * * * *").preset).toBe("hourly");
|
|
expect(parseCron("30 * * * *").parts.minute).toBe(30);
|
|
});
|
|
|
|
test("daily: M H * * *", () => {
|
|
const r = parseCron("0 9 * * *");
|
|
expect(r.preset).toBe("daily");
|
|
expect(r.parts).toMatchObject({ minute: 0, hour: 9 });
|
|
});
|
|
|
|
test("weekly: M H * * DOW", () => {
|
|
const r = parseCron("0 9 * * 1,3");
|
|
expect(r.preset).toBe("weekly");
|
|
expect(r.parts.weekdays).toEqual(["mon", "wed"]);
|
|
});
|
|
|
|
test("weekly maps 0 and 7 to sunday", () => {
|
|
expect(parseCron("0 9 * * 0").parts.weekdays).toEqual(["sun"]);
|
|
expect(parseCron("0 9 * * 7").parts.weekdays).toEqual(["sun"]);
|
|
});
|
|
|
|
test("monthly: M H DOM * *", () => {
|
|
const r = parseCron("0 9 1 * *");
|
|
expect(r.preset).toBe("monthly");
|
|
expect(r.parts.dayOfMonth).toBe(1);
|
|
});
|
|
|
|
test("non-canonical forms fall back to custom", () => {
|
|
expect(parseCron("*/5 * * * *").preset).toBe("custom");
|
|
expect(parseCron("0 9,10 * * *").preset).toBe("custom");
|
|
expect(parseCron("0 9 * * 1-5").preset).toBe("custom");
|
|
expect(parseCron("garbage").preset).toBe("custom");
|
|
expect(parseCron("garbage").parts.raw).toBe("garbage");
|
|
});
|
|
});
|
|
|
|
describe("describeSchedule", () => {
|
|
const baseCron = {
|
|
minute: 0,
|
|
hour: 9,
|
|
weekdays: [],
|
|
dayOfMonth: 1,
|
|
} as CronParts;
|
|
|
|
test("once renders wall time + timezone (en)", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "once",
|
|
runAtLocal: "2026-07-02T09:00",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Once at 2026-07-02 09:00 (Asia/Shanghai)");
|
|
});
|
|
|
|
test("daily en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "daily",
|
|
parts: baseCron,
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every day at 09:00 (UTC)");
|
|
});
|
|
|
|
test("daily zh", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "daily",
|
|
parts: baseCron,
|
|
timezone: "UTC",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每天 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly en lists weekday abbreviations", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: ["mon", "wed"] },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every Mon, Wed at 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly zh lists 周X", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: ["mon", "wed", "fri"] },
|
|
timezone: "UTC",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每周 周一、周三、周五 09:00 (UTC)");
|
|
});
|
|
|
|
test("weekly with no weekdays falls back to daily wording", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "weekly",
|
|
parts: { ...baseCron, weekdays: [] },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every day at 09:00 (UTC)");
|
|
});
|
|
|
|
test("hourly en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "hourly",
|
|
parts: { minute: 30 },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every hour at :30 (UTC)");
|
|
});
|
|
|
|
test("monthly en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "monthly",
|
|
parts: { minute: 0, hour: 9, dayOfMonth: 1 },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("On day 1 of every month at 09:00 (UTC)");
|
|
});
|
|
|
|
test("custom en echoes the expression", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "cron",
|
|
preset: "custom",
|
|
parts: { raw: "*/5 * * * *" },
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Custom: */5 * * * * (UTC)");
|
|
});
|
|
|
|
test("interval minutes en/zh omit timezone", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "interval",
|
|
intervalAmount: 90,
|
|
intervalUnit: "minutes",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every 90 minutes");
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "interval",
|
|
intervalAmount: 90,
|
|
intervalUnit: "minutes",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每 90 分钟");
|
|
});
|
|
|
|
test("interval singular hour en", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "interval",
|
|
intervalAmount: 1,
|
|
intervalUnit: "hours",
|
|
timezone: "UTC",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every hour");
|
|
});
|
|
|
|
test("interval seconds en/zh omit timezone", () => {
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "interval",
|
|
intervalAmount: 90,
|
|
intervalUnit: "seconds",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"en",
|
|
),
|
|
).toBe("Every 90 seconds");
|
|
expect(
|
|
describeSchedule(
|
|
{
|
|
scheduleType: "interval",
|
|
intervalAmount: 90,
|
|
intervalUnit: "seconds",
|
|
timezone: "Asia/Shanghai",
|
|
},
|
|
"zh",
|
|
),
|
|
).toBe("每 90 秒");
|
|
});
|
|
});
|
|
|
|
describe("interval conversion", () => {
|
|
test("minutes and hours convert to seconds", () => {
|
|
expect(intervalToSeconds(90, "seconds")).toBe(90);
|
|
expect(intervalToSeconds(90, "minutes")).toBe(5400);
|
|
expect(intervalToSeconds(2, "hours")).toBe(7200);
|
|
});
|
|
|
|
test("whole hours stay in hours; whole minutes stay in minutes", () => {
|
|
expect(secondsToInterval(7200)).toEqual({ amount: 2, unit: "hours" });
|
|
expect(secondsToInterval(5400)).toEqual({ amount: 90, unit: "minutes" });
|
|
expect(secondsToInterval(120)).toEqual({ amount: 2, unit: "minutes" });
|
|
});
|
|
|
|
test("edit/duplicate round-trip keeps intervals that are not whole minutes", () => {
|
|
const stored = 90;
|
|
const displayed = secondsToInterval(stored);
|
|
expect(displayed).toEqual({ amount: 90, unit: "seconds" });
|
|
expect(intervalToSeconds(displayed.amount, displayed.unit)).toBe(stored);
|
|
});
|
|
|
|
test("seconds unit clamps below the default 60s server floor", () => {
|
|
expect(minIntervalAmount("seconds")).toBe(60);
|
|
expect(minIntervalAmount("minutes")).toBe(1);
|
|
expect(minIntervalAmount("hours")).toBe(1);
|
|
expect(clampIntervalAmount(30, "seconds")).toBe(60);
|
|
expect(clampIntervalAmount(90, "seconds")).toBe(90);
|
|
expect(clampIntervalAmount(1, "minutes")).toBe(1);
|
|
});
|
|
|
|
test("hasScheduleSpec accepts interval every_seconds", () => {
|
|
expect(hasScheduleSpec({ every_seconds: 90 })).toBe(true);
|
|
expect(hasScheduleSpec({ cron: "0 9 * * *" })).toBe(true);
|
|
expect(hasScheduleSpec({ run_at: "2026-07-02T01:00:00+00:00" })).toBe(true);
|
|
expect(hasScheduleSpec({})).toBe(false);
|
|
expect(hasScheduleSpec({ every_seconds: 0 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("zonedLocalToUtcIso", () => {
|
|
test("Asia/Shanghai is UTC-8 (wall 09:00 -> 01:00Z)", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai")).toBe(
|
|
"2026-07-02T01:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("UTC passes through", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "UTC")).toBe(
|
|
"2026-07-02T09:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("America/New_York July is EDT (-04:00)", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "America/New_York")).toBe(
|
|
"2026-07-02T13:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("America/New_York January is EST (-05:00) — DST season flip", () => {
|
|
expect(zonedLocalToUtcIso("2026-01-15T09:00", "America/New_York")).toBe(
|
|
"2026-01-15T14:00:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("Asia/Kolkata half-hour offset UTC+5:30", () => {
|
|
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Kolkata")).toBe(
|
|
"2026-07-02T03:30:00+00:00",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("utcToZonedLocalInput", () => {
|
|
test("Shanghai +8: 01:00Z -> 09:00 wall", () => {
|
|
expect(
|
|
utcToZonedLocalInput("2026-07-02T01:00:00+00:00", "Asia/Shanghai"),
|
|
).toBe("2026-07-02T09:00");
|
|
});
|
|
|
|
test("New_York EDT: 13:00Z -> 09:00 wall", () => {
|
|
expect(
|
|
utcToZonedLocalInput("2026-07-02T13:00:00+00:00", "America/New_York"),
|
|
).toBe("2026-07-02T09:00");
|
|
});
|
|
|
|
test("invalid -> empty string", () => {
|
|
expect(utcToZonedLocalInput("not-a-date", "UTC")).toBe("");
|
|
});
|
|
|
|
test("round-trips with zonedLocalToUtcIso", () => {
|
|
const iso = zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai");
|
|
expect(utcToZonedLocalInput(iso, "Asia/Shanghai")).toBe("2026-07-02T09:00");
|
|
});
|
|
});
|
|
|
|
describe("zonedLocalToUtcIso DST transitions", () => {
|
|
// US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08.
|
|
test("New_York wall time after spring-forward uses the post-transition offset", () => {
|
|
// 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z.
|
|
expect(zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York")).toBe(
|
|
"2026-03-08T07:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("New_York wall time before spring-forward keeps the EST offset", () => {
|
|
expect(zonedLocalToUtcIso("2026-03-08T01:30", "America/New_York")).toBe(
|
|
"2026-03-08T06:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
// US fall-back 2026: clocks repeat 01:00-02:00 EDT->EST on 2026-11-01.
|
|
test("New_York ambiguous fall-back wall time resolves deterministically", () => {
|
|
expect(zonedLocalToUtcIso("2026-11-01T01:30", "America/New_York")).toBe(
|
|
"2026-11-01T05:30:00+00:00",
|
|
);
|
|
});
|
|
|
|
test("create -> edit round-trip survives spring-forward", () => {
|
|
const iso = zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York");
|
|
expect(utcToZonedLocalInput(iso, "America/New_York")).toBe(
|
|
"2026-03-08T03:30",
|
|
);
|
|
});
|
|
|
|
test("no-DST timezone is unaffected", () => {
|
|
expect(zonedLocalToUtcIso("2026-03-08T03:30", "Asia/Shanghai")).toBe(
|
|
"2026-03-07T19:30:00+00:00",
|
|
);
|
|
});
|
|
});
|