chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { calculateNextScheduledTimestampFromNow } from "../app/v3/utils/calculateNextSchedule.server";
|
||||
|
||||
describe("calculateNextScheduledTimestampFromNow", () => {
|
||||
beforeEach(() => {
|
||||
// Mock the current time to make tests deterministic
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-01T12:30:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should calculate next run time for a recent timestamp", () => {
|
||||
const schedule = "0 * * * *"; // Every hour
|
||||
const _lastRun = new Date("2024-01-01T11:00:00.000Z"); // 1.5 hours ago
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
|
||||
// Should be 13:00 (next hour after current time 12:30)
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T13:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("should handle timezone correctly", () => {
|
||||
const schedule = "0 * * * *"; // Every hour
|
||||
const _lastRun = new Date("2024-01-01T11:00:00.000Z");
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, "America/New_York");
|
||||
|
||||
// The exact time will depend on timezone calculation, but should be in the future
|
||||
expect(nextRun).toBeInstanceOf(Date);
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test("should efficiently handle very old timestamps (performance fix)", () => {
|
||||
const schedule = "*/1 * * * *"; // Every minute
|
||||
const _veryOldTimestamp = new Date("2020-01-01T00:00:00.000Z"); // 4 years ago
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should complete quickly (under 10ms) instead of iterating millions of times
|
||||
expect(duration).toBeLessThan(10);
|
||||
|
||||
// Should still return a valid future timestamp
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
// Should be the next minute after current time (12:31)
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
||||
});
|
||||
|
||||
test("should still work correctly when timestamp is within threshold", () => {
|
||||
const schedule = "0 */2 * * *"; // Every 2 hours
|
||||
const _recentTimestamp = new Date("2024-01-01T10:00:00.000Z"); // 2.5 hours ago
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
|
||||
// Should properly iterate: 10:00 -> 12:00 -> 14:00 (since current time is 12:30)
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T14:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("should handle frequent schedules with old timestamps efficiently", () => {
|
||||
const schedule = "*/5 * * * *"; // Every 5 minutes
|
||||
const _oldTimestamp = new Date("2023-12-01T00:00:00.000Z"); // Over a month ago
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should be fast due to dynamic skip-ahead optimization
|
||||
expect(duration).toBeLessThan(10);
|
||||
|
||||
// Should return next 5-minute interval after current time
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
|
||||
});
|
||||
|
||||
test("should work with complex cron expressions", () => {
|
||||
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
|
||||
const _oldTimestamp = new Date("2022-01-01T00:00:00.000Z"); // Very old (beyond 1hr threshold)
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
|
||||
// Should return a valid future Monday at 9 AM
|
||||
expect(nextRun.getHours()).toBe(9);
|
||||
expect(nextRun.getMinutes()).toBe(0);
|
||||
expect(nextRun.getDay()).toBe(1); // Monday
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test("performance: dynamic optimization for extreme scenarios", () => {
|
||||
// This test simulates the exact scenario that was causing event loop lag
|
||||
const schedule = "* * * * *"; // Every minute (very frequent)
|
||||
const _extremelyOldTimestamp = new Date("2000-01-01T00:00:00.000Z"); // 24 years ago
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should complete extremely quickly due to dynamic skip-ahead
|
||||
expect(duration).toBeLessThan(5);
|
||||
|
||||
// Should still return the correct next minute
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
||||
});
|
||||
|
||||
test("dynamic optimization: 23h59m old now handled efficiently", () => {
|
||||
// This should now be handled efficiently regardless of being "just under" a threshold
|
||||
const schedule = "* * * * *"; // Every minute
|
||||
const _oldTimestamp = new Date("2023-12-31T12:31:00.000Z"); // 23h59m ago
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should be fast due to dynamic skip-ahead (1439 steps > 10 threshold)
|
||||
expect(duration).toBeLessThan(10);
|
||||
|
||||
// Should return correct result
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T12:31:00.000Z"));
|
||||
});
|
||||
|
||||
test("small intervals still use normal iteration", () => {
|
||||
// This should use normal iteration since it's only a few steps
|
||||
const schedule = "*/5 * * * *"; // Every 5 minutes
|
||||
const _recentTimestamp = new Date("2024-01-01T12:00:00.000Z"); // 30 minutes ago (6 steps)
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should still be reasonably fast with normal iteration
|
||||
expect(duration).toBeLessThan(50);
|
||||
|
||||
// Should return next 5-minute interval
|
||||
expect(nextRun).toEqual(new Date("2024-01-01T12:35:00.000Z"));
|
||||
});
|
||||
|
||||
test("should work with weekly schedules and old timestamps", () => {
|
||||
const schedule = "0 9 * * MON"; // Every Monday at 9 AM
|
||||
const _oldTimestamp = new Date("2023-12-25T09:00:00.000Z"); // Old Monday
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should be fast and still calculate correctly from the old timestamp
|
||||
expect(duration).toBeLessThan(50);
|
||||
|
||||
// Should return a valid future Monday at 9 AM
|
||||
expect(nextRun.getHours()).toBe(9);
|
||||
expect(nextRun.getMinutes()).toBe(0);
|
||||
expect(nextRun.getDay()).toBe(1); // Monday
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test("weekly schedule with 2-hour old timestamp should calculate properly", () => {
|
||||
// This tests your specific concern about weekly schedules
|
||||
const schedule = "0 14 * * SUN"; // Every Sunday at 2 PM
|
||||
const _twoHoursAgo = new Date("2024-01-01T10:30:00.000Z"); // 2 hours before current time (12:30)
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
|
||||
// Should properly calculate the next Sunday at 2 PM, not skip to "now"
|
||||
expect(nextRun.getHours()).toBe(14);
|
||||
expect(nextRun.getMinutes()).toBe(0);
|
||||
expect(nextRun.getDay()).toBe(0); // Sunday
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateNextScheduledTimestampFromNow - Fuzzy Testing", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T12:30:00.000Z")); // Monday, mid-day
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper function to generate random cron expressions
|
||||
function generateRandomCronExpression(): string {
|
||||
const patterns = [
|
||||
// Minutes
|
||||
"*/1 * * * *", // Every minute
|
||||
"*/5 * * * *", // Every 5 minutes
|
||||
"*/15 * * * *", // Every 15 minutes
|
||||
"30 * * * *", // Every hour at 30 minutes
|
||||
|
||||
// Hours
|
||||
"0 * * * *", // Every hour
|
||||
"0 */2 * * *", // Every 2 hours
|
||||
"0 */6 * * *", // Every 6 hours
|
||||
"0 9 * * *", // Daily at 9 AM
|
||||
"0 14 * * *", // Daily at 2 PM
|
||||
|
||||
// Days
|
||||
"0 9 * * 1", // Every Monday at 9 AM
|
||||
"0 14 * * 5", // Every Friday at 2 PM
|
||||
"0 10 * * 1-5", // Weekdays at 10 AM
|
||||
"0 0 * * 0", // Every Sunday at midnight
|
||||
|
||||
// Weekly/Monthly
|
||||
"0 9 * * MON", // Every Monday at 9 AM
|
||||
"0 12 1 * *", // First of every month at noon
|
||||
"0 15 15 * *", // 15th of every month at 3 PM
|
||||
|
||||
// Complex patterns
|
||||
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
|
||||
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
|
||||
];
|
||||
|
||||
return patterns[Math.floor(Math.random() * patterns.length)];
|
||||
}
|
||||
|
||||
// Helper function to generate random timestamps
|
||||
function generateRandomTimestamp(): Date {
|
||||
const now = Date.now();
|
||||
const possibilities = [
|
||||
// Recent timestamps (within last few hours)
|
||||
new Date(now - Math.random() * 4 * 60 * 60 * 1000),
|
||||
|
||||
// Old timestamps (days ago)
|
||||
new Date(now - Math.random() * 30 * 24 * 60 * 60 * 1000),
|
||||
|
||||
// Very old timestamps (months/years ago)
|
||||
new Date(now - Math.random() * 365 * 24 * 60 * 60 * 1000),
|
||||
|
||||
// Extremely old timestamps
|
||||
new Date(now - Math.random() * 10 * 365 * 24 * 60 * 60 * 1000),
|
||||
|
||||
// Edge case: exactly now
|
||||
new Date(now),
|
||||
|
||||
// Edge case: 1ms ago
|
||||
new Date(now - 1),
|
||||
|
||||
// Edge case: future timestamp (should be handled gracefully)
|
||||
new Date(now + Math.random() * 24 * 60 * 60 * 1000),
|
||||
];
|
||||
|
||||
return possibilities[Math.floor(Math.random() * possibilities.length)];
|
||||
}
|
||||
|
||||
test("fuzzy test: invariants should hold for random scenarios", () => {
|
||||
const numTests = 50;
|
||||
|
||||
for (let i = 0; i < numTests; i++) {
|
||||
const schedule = generateRandomCronExpression();
|
||||
const lastTimestamp = generateRandomTimestamp();
|
||||
const timezone = Math.random() > 0.7 ? "America/New_York" : null;
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Invariant 1: Result should always be a valid Date
|
||||
expect(nextRun).toBeInstanceOf(Date);
|
||||
expect(nextRun.getTime()).not.toBeNaN();
|
||||
|
||||
// Invariant 2: Result should be in the future (or equal to now if lastTimestamp was in future)
|
||||
if (lastTimestamp.getTime() <= Date.now()) {
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
}
|
||||
|
||||
// Invariant 3: Performance should be reasonable (no event loop lag)
|
||||
expect(duration).toBeLessThan(100); // Should complete within 100ms
|
||||
|
||||
// Invariant 4: Function should be deterministic
|
||||
const nextRun2 = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
||||
expect(nextRun.getTime()).toBe(nextRun2.getTime());
|
||||
} catch (error) {
|
||||
// If there's an error, log the inputs for debugging
|
||||
console.error(
|
||||
`Failed with schedule: ${schedule}, lastTimestamp: ${lastTimestamp.toISOString()}, timezone: ${timezone}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: performance under stress with frequent schedules", () => {
|
||||
const frequentSchedules = ["* * * * *", "*/2 * * * *", "*/5 * * * *"];
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const schedule = frequentSchedules[Math.floor(Math.random() * frequentSchedules.length)];
|
||||
|
||||
// Generate very old timestamps that would cause many iterations without optimization
|
||||
const _veryOldTimestamp = new Date(
|
||||
Date.now() - Math.random() * 5 * 365 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Should complete quickly even with very old timestamps
|
||||
expect(duration).toBeLessThan(20);
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: edge cases around daylight saving time", () => {
|
||||
// Test around DST transition dates (spring forward, fall back)
|
||||
const dstTestDates = [
|
||||
"2024-03-10T06:00:00.000Z", // Around US spring DST
|
||||
"2024-11-03T06:00:00.000Z", // Around US fall DST
|
||||
"2024-03-31T01:00:00.000Z", // Around EU spring DST
|
||||
"2024-10-27T01:00:00.000Z", // Around EU fall DST
|
||||
];
|
||||
|
||||
const timezones = ["America/New_York", "Europe/London", "America/Los_Angeles"];
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const schedule = generateRandomCronExpression();
|
||||
const testDate = dstTestDates[Math.floor(Math.random() * dstTestDates.length)];
|
||||
const timezone = timezones[Math.floor(Math.random() * timezones.length)];
|
||||
|
||||
vi.setSystemTime(new Date(testDate));
|
||||
|
||||
const _lastTimestamp = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, timezone);
|
||||
|
||||
// Should handle DST transitions gracefully
|
||||
expect(nextRun).toBeInstanceOf(Date);
|
||||
expect(nextRun.getTime()).not.toBeNaN();
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: boundary conditions", () => {
|
||||
const boundaryTests = [
|
||||
// End of month transitions
|
||||
{ time: "2024-02-29T23:59:59.000Z", schedule: "0 0 1 * *" }, // Leap year to March 1st
|
||||
{ time: "2024-04-30T23:59:59.000Z", schedule: "0 0 31 * *" }, // April 30th to May 31st
|
||||
|
||||
// End of year
|
||||
{ time: "2024-12-31T23:59:59.000Z", schedule: "0 0 1 1 *" }, // New Year
|
||||
|
||||
// Weekday transitions
|
||||
{ time: "2024-01-15T06:59:59.000Z", schedule: "0 7 * * MON" }, // Monday morning
|
||||
|
||||
// Hour boundaries
|
||||
{ time: "2024-01-15T11:59:59.000Z", schedule: "0 12 * * *" }, // Noon
|
||||
{ time: "2024-01-15T23:59:59.000Z", schedule: "0 0 * * *" }, // Midnight
|
||||
];
|
||||
|
||||
for (const test of boundaryTests) {
|
||||
vi.setSystemTime(new Date(test.time));
|
||||
|
||||
// Test with timestamps both before and after the boundary
|
||||
const _beforeBoundary = new Date(Date.now() - 1000);
|
||||
const _afterBoundary = new Date(Date.now() + 1000);
|
||||
|
||||
const nextRun1 = calculateNextScheduledTimestampFromNow(test.schedule, null);
|
||||
const nextRun2 = calculateNextScheduledTimestampFromNow(test.schedule, null);
|
||||
|
||||
expect(nextRun1.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(nextRun2.getTime()).toBeGreaterThan(Date.now());
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: complex cron expressions", () => {
|
||||
const complexSchedules = [
|
||||
"0 9,17 * * 1-5", // 9 AM and 5 PM on weekdays
|
||||
"30 8-18/2 * * *", // Every 2 hours from 8:30 AM to 6:30 PM
|
||||
"0 0 1,15 * *", // 1st and 15th of every month
|
||||
"0 12 * * MON#2", // Second Monday of every month (if supported)
|
||||
"0 0 L * *", // Last day of month (if supported)
|
||||
"15,45 */2 * * *", // 15 and 45 minutes past every 2nd hour
|
||||
];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const schedule = complexSchedules[Math.floor(Math.random() * complexSchedules.length)];
|
||||
const lastTimestamp = generateRandomTimestamp();
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
expect(nextRun).toBeInstanceOf(Date);
|
||||
expect(duration).toBeLessThan(100);
|
||||
|
||||
if (lastTimestamp.getTime() <= Date.now()) {
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
}
|
||||
} catch (error) {
|
||||
// Some complex expressions might not be supported, that's okay
|
||||
if (
|
||||
!(error as Error).message.includes("not supported") &&
|
||||
!(error as Error).message.includes("Invalid")
|
||||
) {
|
||||
console.error(`Unexpected error with schedule: ${schedule}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: consistency across multiple calls", () => {
|
||||
// Test that the function is consistent when called multiple times with same inputs
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const schedule = generateRandomCronExpression();
|
||||
const _lastTimestamp = generateRandomTimestamp();
|
||||
const timezone = Math.random() > 0.5 ? "UTC" : "America/New_York";
|
||||
|
||||
const results: Date[] = [];
|
||||
for (let j = 0; j < 5; j++) {
|
||||
results.push(calculateNextScheduledTimestampFromNow(schedule, timezone));
|
||||
}
|
||||
|
||||
// All results should be identical
|
||||
for (let j = 1; j < results.length; j++) {
|
||||
expect(results[j].getTime()).toBe(results[0].getTime());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fuzzy test: optimization threshold boundary (around 10 steps)", () => {
|
||||
// Test cases specifically around the 10-step optimization threshold
|
||||
const testCases = [
|
||||
{ schedule: "*/5 * * * *", minutesAgo: 50 }, // Exactly 10 steps
|
||||
{ schedule: "*/5 * * * *", minutesAgo: 55 }, // 11 steps (should optimize)
|
||||
{ schedule: "*/5 * * * *", minutesAgo: 45 }, // 9 steps (should not optimize)
|
||||
{ schedule: "*/10 * * * *", minutesAgo: 100 }, // Exactly 10 steps
|
||||
{ schedule: "*/10 * * * *", minutesAgo: 110 }, // 11 steps (should optimize)
|
||||
{ schedule: "*/15 * * * *", minutesAgo: 150 }, // Exactly 10 steps
|
||||
{ schedule: "*/1 * * * *", minutesAgo: 10 }, // Exactly 10 steps
|
||||
{ schedule: "*/1 * * * *", minutesAgo: 11 }, // 11 steps (should optimize)
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const _lastTimestamp = new Date(Date.now() - testCase.minutesAgo * 60 * 1000);
|
||||
|
||||
const startTime = performance.now();
|
||||
const nextRun = calculateNextScheduledTimestampFromNow(testCase.schedule, null);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// All cases should complete quickly and return valid results
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(nextRun.getTime()).toBeGreaterThan(Date.now());
|
||||
expect(nextRun).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user