Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

82 lines · 3.5 KBRaw History
1import { describe, expect, it } from "vitest";
2import { CronError, cronMatches, nextCronMatch, parseCron, validateCron } from "../src/index.js";
3
4const d = (iso: string) => new Date(iso);
5
6describe("parseCron", () => {
7 it("parses wildcards, lists, ranges and steps", () => {
8 const c = parseCron("*/15 0-6/2 1,15 * *");
9 expect([...c.minutes]).toEqual([0, 15, 30, 45]);
10 expect([...c.hours]).toEqual([0, 2, 4, 6]);
11 expect([...c.daysOfMonth]).toEqual([1, 15]);
12 expect(c.months.size).toBe(12);
13 expect(c.daysOfWeek.size).toBe(7);
14 });
15
16 it("supports month and day names (case-insensitive) and 7 as Sunday", () => {
17 const c = parseCron("0 0 * jan-mar,DEC MON-FRI");
18 expect([...c.months]).toEqual([1, 2, 3, 12]);
19 expect([...c.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
20 expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
21 expect([...parseCron("0 0 * * 5-7").daysOfWeek].sort()).toEqual([0, 5, 6]);
22 });
23
24 it("supports a start/step without a range", () => {
25 expect([...parseCron("5/20 * * * *").minutes]).toEqual([5, 25, 45]);
26 });
27
28 const invalid = ["", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8",
29 "*/0 * * * *", "5-1 * * * *", "a * * * *", "1,,2 * * * *", "* * * foo *"];
30 for (const cron of invalid) {
31 it(`rejects ${JSON.stringify(cron)}`, () => {
32 expect(() => parseCron(cron)).toThrow(CronError);
33 expect(validateCron(cron)).toBeTypeOf("string");
34 });
35 }
36});
37
38describe("cronMatches", () => {
39 it("matches in UTC", () => {
40 expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00Z"))).toBe(true);
41 expect(cronMatches("30 5 * * *", d("2026-10-12T05:31:00Z"))).toBe(false);
42 expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00+02:00"))).toBe(false);
43 });
44
45 it("matches weekdays", () => {
46 // 2026-10-12 is a Monday.
47 expect(cronMatches("0 9 * * MON", d("2026-10-12T09:00:00Z"))).toBe(true);
48 expect(cronMatches("0 9 * * 2", d("2026-10-12T09:00:00Z"))).toBe(false);
49 expect(cronMatches("30 5 * * 1,3", d("2026-10-14T05:30:00Z"))).toBe(true);
50 });
51
52 it("day-of-month OR day-of-week when both are restricted", () => {
53 // 1st of month or Friday
54 expect(cronMatches("0 0 1 * 5", d("2026-10-01T00:00:00Z"))).toBe(true); // Thursday, 1st
55 expect(cronMatches("0 0 1 * 5", d("2026-10-16T00:00:00Z"))).toBe(true); // Friday
56 expect(cronMatches("0 0 1 * 5", d("2026-10-15T00:00:00Z"))).toBe(false);
57 // With dow wildcard, only dom applies.
58 expect(cronMatches("0 0 1 * *", d("2026-10-16T00:00:00Z"))).toBe(false);
59 expect(cronMatches("0 0 * * 5", d("2026-10-01T00:00:00Z"))).toBe(false);
60 });
61
62 it("matches months", () => {
63 expect(cronMatches("0 0 1 JAN *", d("2027-01-01T00:00:00Z"))).toBe(true);
64 expect(cronMatches("0 0 1 JAN *", d("2026-11-01T00:00:00Z"))).toBe(false);
65 });
66
67 it("ignores seconds", () => {
68 expect(cronMatches("*/5 * * * *", d("2026-10-12T10:05:42Z"))).toBe(true);
69 });
70});
71
72describe("nextCronMatch", () => {
73 it("finds the next minute strictly after the given date", () => {
74 expect(nextCronMatch("*/15 * * * *", d("2026-10-12T10:00:00Z"))?.toISOString()).toBe("2026-10-12T10:15:00.000Z");
75 expect(nextCronMatch("0 9 * * MON", d("2026-10-12T09:00:00Z"))?.toISOString()).toBe("2026-10-19T09:00:00.000Z");
76 expect(nextCronMatch("0 0 29 2 *", d("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2028-02-29T00:00:00.000Z");
77 });
78
79 it("returns null when it never matches", () => {
80 expect(nextCronMatch("0 0 31 2 *", d("2026-10-12T00:00:00Z"))).toBe(null);
81 });
82});