Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

423 lines
15 KiB
TypeScript

/**
* RRULE recurrence module — parse/normalize validation, DST-correct expansion,
* next-occurrence, and COUNT/UNTIL termination.
*
* DST facts used below (America/New_York, 2026): spring-forward on Sunday
* 2026-03-08 (EST→EDT, UTC-5→UTC-4), fall-back on Sunday 2026-11-01
* (EDT→EST). A recurring 9am local event is 14:00Z under EST and 13:00Z under
* EDT — the expansion must keep 9am local (exactly one fire per local day,
* never a double-fire or a skip), mirroring the scheduled-task cron DST fix.
*/
import { describe, expect, it } from "vitest";
import { CalendarServiceError } from "../src/internal/errors.js";
import {
describeRecurrence,
expandRecurrenceOccurrences,
firstRecurrenceRule,
nextRecurrenceOccurrence,
normalizeRecurrence,
normalizeRecurrenceScope,
parseRecurrenceRule,
recurrenceLinesFrom,
recurringEventIdFrom,
} from "../src/internal/recurrence.js";
const NY = "America/New_York";
function isoAll(dates: Date[]): string[] {
return dates.map((date) => date.toISOString());
}
describe("parseRecurrenceRule", () => {
it("parses a weekly BYDAY rule with and without the RRULE: prefix", () => {
for (const line of [
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE",
"FREQ=WEEKLY;BYDAY=MO,WE",
]) {
const rule = parseRecurrenceRule(line);
expect(rule.freq).toBe("WEEKLY");
expect(rule.interval).toBe(1);
expect(rule.byDay).toEqual([1, 3]);
expect(rule.beyondExpansionSubset).toBe(false);
}
});
it("parses INTERVAL, COUNT, UNTIL (date and datetime), BYMONTHDAY", () => {
expect(parseRecurrenceRule("RRULE:FREQ=DAILY;INTERVAL=3").interval).toBe(3);
expect(parseRecurrenceRule("RRULE:FREQ=DAILY;COUNT=5").count).toBe(5);
expect(
parseRecurrenceRule("RRULE:FREQ=DAILY;UNTIL=20260310T140000Z").untilMs,
).toBe(Date.UTC(2026, 2, 10, 14, 0, 0));
expect(parseRecurrenceRule("RRULE:FREQ=DAILY;UNTIL=20260310").untilMs).toBe(
Date.UTC(2026, 2, 10, 23, 59, 59),
);
expect(
parseRecurrenceRule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1").byMonthDay,
).toEqual([15, -1]);
});
it("flags provider-valid parts outside the local expansion subset", () => {
expect(
parseRecurrenceRule("RRULE:FREQ=MONTHLY;BYDAY=2MO").beyondExpansionSubset,
).toBe(true);
expect(
parseRecurrenceRule("RRULE:FREQ=YEARLY;BYMONTH=3").beyondExpansionSubset,
).toBe(true);
});
it("rejects malformed rules with a 400 CalendarServiceError", () => {
const invalid = [
"",
"RRULE:",
"RRULE:FREQ=HOURLY",
"RRULE:BYDAY=MO",
"RRULE:FREQ=WEEKLY;BYDAY=XX",
"RRULE:FREQ=DAILY;INTERVAL=0",
"RRULE:FREQ=DAILY;COUNT=0",
"RRULE:FREQ=DAILY;UNTIL=tomorrow",
"RRULE:FREQ=DAILY;COUNT=3;UNTIL=20260310",
"RRULE:FREQ=MONTHLY;BYMONTHDAY=0",
"RRULE:FREQ=MONTHLY;BYMONTHDAY=45",
"RRULE:FREQ=DAILY;NONSENSE=1",
"every tuesday at 9",
];
for (const line of invalid) {
expect(() => parseRecurrenceRule(line), line).toThrowError(
CalendarServiceError,
);
try {
parseRecurrenceRule(line);
} catch (error) {
expect((error as CalendarServiceError).status).toBe(400);
}
}
});
});
describe("normalizeRecurrence", () => {
it("canonicalizes strings and arrays to uppercase RRULE: lines", () => {
expect(normalizeRecurrence("freq=weekly;byday=mo")).toEqual([
"RRULE:FREQ=WEEKLY;BYDAY=MO",
]);
expect(normalizeRecurrence(["rrule:freq=daily;count=10"])).toEqual([
"RRULE:FREQ=DAILY;COUNT=10",
]);
});
it("passes EXDATE/RDATE lines through and drops empties", () => {
expect(
normalizeRecurrence([
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE;TZID=America/New_York:20260316T090000",
" ",
]),
).toEqual([
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE;TZID=America/New_York:20260316T090000",
]);
});
it("returns undefined for empty input and throws for junk", () => {
expect(normalizeRecurrence(undefined)).toBeUndefined();
expect(normalizeRecurrence(null)).toBeUndefined();
expect(normalizeRecurrence([])).toBeUndefined();
expect(normalizeRecurrence([" "])).toBeUndefined();
expect(() => normalizeRecurrence("weekly on mondays")).toThrowError(
CalendarServiceError,
);
expect(() => normalizeRecurrence([42])).toThrowError(CalendarServiceError);
});
});
describe("expandRecurrenceOccurrences — DST boundaries", () => {
it("keeps 9am local across the spring-forward boundary (one per day)", () => {
// 2026-03-06T09:00 EST = 14:00Z; DST starts Sunday 2026-03-08.
const rule = parseRecurrenceRule("RRULE:FREQ=DAILY");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-03-06T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2026-03-11T00:00:00.000Z"),
});
expect(isoAll(occurrences)).toEqual([
"2026-03-06T14:00:00.000Z", // Fri 09:00 EST
"2026-03-07T14:00:00.000Z", // Sat 09:00 EST
"2026-03-08T13:00:00.000Z", // Sun 09:00 EDT — offset shifts, local time holds
"2026-03-09T13:00:00.000Z", // Mon 09:00 EDT
"2026-03-10T13:00:00.000Z", // Tue 09:00 EDT
]);
});
it("keeps 9am local across the fall-back boundary (no double-fire)", () => {
// 2026-10-30T09:00 EDT = 13:00Z; DST ends Sunday 2026-11-01.
const rule = parseRecurrenceRule("RRULE:FREQ=DAILY");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-10-30T13:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2026-11-04T00:00:00.000Z"),
});
expect(isoAll(occurrences)).toEqual([
"2026-10-30T13:00:00.000Z", // Fri 09:00 EDT
"2026-10-31T13:00:00.000Z", // Sat 09:00 EDT
"2026-11-01T14:00:00.000Z", // Sun 09:00 EST — exactly one fire
"2026-11-02T14:00:00.000Z", // Mon 09:00 EST
"2026-11-03T14:00:00.000Z", // Tue 09:00 EST
]);
});
it("expands weekly BYDAY across the DST boundary", () => {
// Monday 2026-03-02T09:00 EST; next Mondays fall after spring-forward.
const rule = parseRecurrenceRule("RRULE:FREQ=WEEKLY;BYDAY=MO");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-03-02T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2026-03-17T00:00:00.000Z"),
});
expect(isoAll(occurrences)).toEqual([
"2026-03-02T14:00:00.000Z", // Mon 09:00 EST
"2026-03-09T13:00:00.000Z", // Mon 09:00 EDT
"2026-03-16T13:00:00.000Z", // Mon 09:00 EDT
]);
});
});
describe("expandRecurrenceOccurrences — rule semantics", () => {
it("honors COUNT termination (DTSTART counts as the first occurrence)", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-03-06T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2027-01-01T00:00:00.000Z"),
});
expect(occurrences).toHaveLength(3);
expect(occurrences.at(-1)?.toISOString()).toBe("2026-03-08T13:00:00.000Z");
});
it("honors UNTIL termination inclusively across the DST shift", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=DAILY;UNTIL=20260310T140000Z");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-03-06T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2027-01-01T00:00:00.000Z"),
});
// Mar 10 09:00 EDT = 13:00Z <= UNTIL 14:00Z → included; Mar 11 excluded.
expect(isoAll(occurrences)).toEqual([
"2026-03-06T14:00:00.000Z",
"2026-03-07T14:00:00.000Z",
"2026-03-08T13:00:00.000Z",
"2026-03-09T13:00:00.000Z",
"2026-03-10T13:00:00.000Z",
]);
});
it("expands every-2-weeks BYDAY pairs from the anchor week", () => {
// Wednesday 2026-06-03T09:00 EDT = 13:00Z; anchor week is Jun 1 (Mon).
const rule = parseRecurrenceRule(
"RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE",
);
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-06-03T13:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2026-07-01T00:00:00.000Z"),
});
// Week 0: Jun 1 (before DTSTART → dropped), Jun 3 (DTSTART).
// Week 2: Jun 15, Jun 17. Week 4: Jun 29.
expect(isoAll(occurrences)).toEqual([
"2026-06-03T13:00:00.000Z",
"2026-06-15T13:00:00.000Z",
"2026-06-17T13:00:00.000Z",
"2026-06-29T13:00:00.000Z",
]);
});
it("skips months without the anchor day for MONTHLY day-31 rules", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=MONTHLY");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-01-31T14:00:00.000Z"), // Jan 31, 09:00 EST
timeZone: NY,
rangeEnd: new Date("2026-06-15T00:00:00.000Z"),
});
// Feb and Apr have no day 31 → skipped per RFC 5545.
expect(isoAll(occurrences)).toEqual([
"2026-01-31T14:00:00.000Z",
"2026-03-31T13:00:00.000Z", // EDT
"2026-05-31T13:00:00.000Z", // EDT
]);
});
it("resolves negative BYMONTHDAY (-1 = last day of month)", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=MONTHLY;BYMONTHDAY=-1");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2026-01-31T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2026-04-01T00:00:00.000Z"),
});
expect(isoAll(occurrences)).toEqual([
"2026-01-31T14:00:00.000Z",
"2026-02-28T14:00:00.000Z",
"2026-03-31T13:00:00.000Z",
]);
});
it("skips Feb 29 anniversaries in non-leap years for YEARLY rules", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=YEARLY");
const occurrences = expandRecurrenceOccurrences({
rule,
startAt: new Date("2024-02-29T14:00:00.000Z"),
timeZone: NY,
rangeEnd: new Date("2029-01-01T00:00:00.000Z"),
});
expect(isoAll(occurrences)).toEqual([
"2024-02-29T14:00:00.000Z",
"2028-02-29T14:00:00.000Z",
]);
});
});
describe("nextRecurrenceOccurrence", () => {
it("finds the next weekly occurrence after a given instant", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=WEEKLY;BYDAY=MO");
const next = nextRecurrenceOccurrence({
rule,
startAt: new Date("2026-03-02T14:00:00.000Z"),
timeZone: NY,
after: new Date("2026-03-03T00:00:00.000Z"),
});
expect(next?.toISOString()).toBe("2026-03-09T13:00:00.000Z");
});
it("finds the next monthly occurrence across a short month", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=MONTHLY");
const next = nextRecurrenceOccurrence({
rule,
startAt: new Date("2026-01-31T14:00:00.000Z"),
timeZone: NY,
after: new Date("2026-02-01T00:00:00.000Z"),
});
expect(next?.toISOString()).toBe("2026-03-31T13:00:00.000Z");
});
it("returns null once COUNT/UNTIL terminate the series", () => {
const counted = parseRecurrenceRule("RRULE:FREQ=DAILY;COUNT=2");
expect(
nextRecurrenceOccurrence({
rule: counted,
startAt: new Date("2026-03-06T14:00:00.000Z"),
timeZone: NY,
after: new Date("2026-03-07T14:00:00.000Z"),
}),
).toBeNull();
const bounded = parseRecurrenceRule(
"RRULE:FREQ=DAILY;UNTIL=20260307T140000Z",
);
expect(
nextRecurrenceOccurrence({
rule: bounded,
startAt: new Date("2026-03-06T14:00:00.000Z"),
timeZone: NY,
after: new Date("2026-03-07T14:00:00.000Z"),
}),
).toBeNull();
});
it("returns null instead of guessing for beyond-subset rules", () => {
const rule = parseRecurrenceRule("RRULE:FREQ=MONTHLY;BYDAY=2MO");
expect(
nextRecurrenceOccurrence({
rule,
startAt: new Date("2026-03-09T13:00:00.000Z"),
timeZone: NY,
after: new Date("2026-03-10T00:00:00.000Z"),
}),
).toBeNull();
});
});
describe("describeRecurrence", () => {
it("describes common rules", () => {
expect(describeRecurrence(["RRULE:FREQ=DAILY"])).toBe("daily");
expect(describeRecurrence(["RRULE:FREQ=DAILY;INTERVAL=2"])).toBe(
"every 2 days",
);
expect(describeRecurrence(["RRULE:FREQ=WEEKLY;BYDAY=MO"])).toBe(
"weekly on Monday",
);
expect(
describeRecurrence(["RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"]),
).toBe("every 2 weeks on Monday and Wednesday");
expect(describeRecurrence(["RRULE:FREQ=MONTHLY;BYMONTHDAY=15"])).toBe(
"monthly on day 15",
);
expect(describeRecurrence(["RRULE:FREQ=DAILY;COUNT=10"])).toBe(
"daily, 10 times",
);
expect(describeRecurrence(["RRULE:FREQ=WEEKLY;UNTIL=20260901"])).toBe(
"weekly until Sep 1, 2026",
);
});
it("returns null for empty input and skips EXDATE-only sets", () => {
expect(describeRecurrence(null)).toBeNull();
expect(describeRecurrence([])).toBeNull();
expect(
describeRecurrence(["EXDATE;TZID=America/New_York:20260316T090000"]),
).toBeNull();
});
});
describe("scope + event helpers", () => {
it("normalizes recurrence scopes and fails closed on junk", () => {
expect(normalizeRecurrenceScope(undefined)).toBeUndefined();
expect(normalizeRecurrenceScope("")).toBeUndefined();
expect(normalizeRecurrenceScope("instance")).toBe("instance");
expect(normalizeRecurrenceScope("Occurrence")).toBe("instance");
expect(normalizeRecurrenceScope("SERIES")).toBe("series");
expect(normalizeRecurrenceScope("all")).toBe("series");
expect(() => normalizeRecurrenceScope("everything")).toThrowError(
CalendarServiceError,
);
expect(() => normalizeRecurrenceScope(7)).toThrowError(
CalendarServiceError,
);
});
it("reads recurringEventId and recurrence from fields or metadata", () => {
expect(
recurringEventIdFrom({ recurringEventId: "master-1", metadata: {} }),
).toBe("master-1");
expect(
recurringEventIdFrom({ metadata: { recurringEventId: "master-2" } }),
).toBe("master-2");
expect(recurringEventIdFrom({ metadata: {} })).toBeNull();
expect(recurringEventIdFrom(null)).toBeNull();
expect(
recurrenceLinesFrom({ recurrence: ["RRULE:FREQ=DAILY"], metadata: {} }),
).toEqual(["RRULE:FREQ=DAILY"]);
expect(
recurrenceLinesFrom({
metadata: { recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO", 42] },
}),
).toEqual(["RRULE:FREQ=WEEKLY;BYDAY=MO"]);
expect(recurrenceLinesFrom({ metadata: {} })).toBeNull();
});
it("parses the first RRULE line out of a mixed recurrence set", () => {
const rule = firstRecurrenceRule([
"EXDATE;TZID=UTC:20260316T090000",
"RRULE:FREQ=WEEKLY;BYDAY=TU",
]);
expect(rule?.freq).toBe("WEEKLY");
expect(rule?.byDay).toEqual([2]);
});
});