mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
feat(schedule-trigger): enhance cron parser with mature library and comprehensive testing (#26002)
This commit is contained in:
@ -11,6 +11,36 @@ describe('cron-parser', () => {
|
||||
expect(isValidCronExpression('0 0 1,15 * *')).toBe(true)
|
||||
})
|
||||
|
||||
test('validates enhanced dayOfWeek syntax', () => {
|
||||
expect(isValidCronExpression('0 9 * * 7')).toBe(true) // Sunday as 7
|
||||
expect(isValidCronExpression('0 9 * * SUN')).toBe(true) // Sunday abbreviation
|
||||
expect(isValidCronExpression('0 9 * * MON')).toBe(true) // Monday abbreviation
|
||||
expect(isValidCronExpression('0 9 * * MON-FRI')).toBe(true) // Range with abbreviations
|
||||
expect(isValidCronExpression('0 9 * * SUN,WED,FRI')).toBe(true) // List with abbreviations
|
||||
})
|
||||
|
||||
test('validates enhanced month syntax', () => {
|
||||
expect(isValidCronExpression('0 9 1 JAN *')).toBe(true) // January abbreviation
|
||||
expect(isValidCronExpression('0 9 1 DEC *')).toBe(true) // December abbreviation
|
||||
expect(isValidCronExpression('0 9 1 JAN-MAR *')).toBe(true) // Range with abbreviations
|
||||
expect(isValidCronExpression('0 9 1 JAN,JUN,DEC *')).toBe(true) // List with abbreviations
|
||||
})
|
||||
|
||||
test('validates special characters', () => {
|
||||
expect(isValidCronExpression('0 9 ? * 1')).toBe(true) // ? wildcard
|
||||
expect(isValidCronExpression('0 9 L * *')).toBe(true) // Last day of month
|
||||
expect(isValidCronExpression('0 9 * * 1#1')).toBe(true) // First Monday of month
|
||||
expect(isValidCronExpression('0 9 * * 1L')).toBe(true) // Last Monday of month
|
||||
})
|
||||
|
||||
test('validates predefined expressions', () => {
|
||||
expect(isValidCronExpression('@yearly')).toBe(true)
|
||||
expect(isValidCronExpression('@monthly')).toBe(true)
|
||||
expect(isValidCronExpression('@weekly')).toBe(true)
|
||||
expect(isValidCronExpression('@daily')).toBe(true)
|
||||
expect(isValidCronExpression('@hourly')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects invalid cron expressions', () => {
|
||||
expect(isValidCronExpression('')).toBe(false)
|
||||
expect(isValidCronExpression('15 10 1')).toBe(false) // Not enough fields
|
||||
@ -19,13 +49,18 @@ describe('cron-parser', () => {
|
||||
expect(isValidCronExpression('15 25 1 * *')).toBe(false) // Invalid hour
|
||||
expect(isValidCronExpression('15 10 32 * *')).toBe(false) // Invalid day
|
||||
expect(isValidCronExpression('15 10 1 13 *')).toBe(false) // Invalid month
|
||||
expect(isValidCronExpression('15 10 1 * 7')).toBe(false) // Invalid day of week
|
||||
expect(isValidCronExpression('15 10 1 * 8')).toBe(false) // Invalid day of week
|
||||
expect(isValidCronExpression('15 10 1 INVALID *')).toBe(false) // Invalid month abbreviation
|
||||
expect(isValidCronExpression('15 10 1 * INVALID')).toBe(false) // Invalid day abbreviation
|
||||
expect(isValidCronExpression('@invalid')).toBe(false) // Invalid predefined expression
|
||||
})
|
||||
|
||||
test('handles edge cases', () => {
|
||||
expect(isValidCronExpression(' 15 10 1 * * ')).toBe(true) // Whitespace
|
||||
expect(isValidCronExpression('0 0 29 2 *')).toBe(true) // Feb 29 (valid in leap years)
|
||||
expect(isValidCronExpression('59 23 31 12 6')).toBe(true) // Max values
|
||||
expect(isValidCronExpression('0 0 29 FEB *')).toBe(true) // Feb 29 with month abbreviation
|
||||
expect(isValidCronExpression('59 23 31 DEC SAT')).toBe(true) // Max values with abbreviations
|
||||
})
|
||||
})
|
||||
|
||||
@ -168,6 +203,10 @@ describe('cron-parser', () => {
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
// Since we're using UTC timezone in this test, the returned dates should
|
||||
// be in the future relative to the current time
|
||||
// Note: our implementation returns dates in "user timezone representation"
|
||||
// but for UTC, this should match the expected behavior
|
||||
expect(date.getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
@ -202,21 +241,269 @@ describe('cron-parser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('enhanced syntax tests', () => {
|
||||
test('handles month abbreviations correctly', () => {
|
||||
const result = parseCronExpression('0 12 1 JAN *') // First day of January at noon
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getMonth()).toBe(0) // January
|
||||
expect(date.getDate()).toBe(1)
|
||||
expect(date.getHours()).toBe(12)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles day abbreviations correctly', () => {
|
||||
const result = parseCronExpression('0 14 * * MON') // Every Monday at 14:00
|
||||
|
||||
expect(result).toHaveLength(5)
|
||||
result.forEach((date) => {
|
||||
expect(date.getDay()).toBe(1) // Monday
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles Sunday as both 0 and 7', () => {
|
||||
const result0 = parseCronExpression('0 10 * * 0') // Sunday as 0
|
||||
const result7 = parseCronExpression('0 10 * * 7') // Sunday as 7
|
||||
const resultSUN = parseCronExpression('0 10 * * SUN') // Sunday as SUN
|
||||
|
||||
expect(result0).toHaveLength(5)
|
||||
expect(result7).toHaveLength(5)
|
||||
expect(resultSUN).toHaveLength(5)
|
||||
|
||||
// All should return Sundays
|
||||
result0.forEach(date => expect(date.getDay()).toBe(0))
|
||||
result7.forEach(date => expect(date.getDay()).toBe(0))
|
||||
resultSUN.forEach(date => expect(date.getDay()).toBe(0))
|
||||
})
|
||||
|
||||
test('handles question mark wildcard', () => {
|
||||
const resultStar = parseCronExpression('0 9 * * 1') // Using *
|
||||
const resultQuestion = parseCronExpression('0 9 ? * 1') // Using ?
|
||||
|
||||
expect(resultStar).toHaveLength(5)
|
||||
expect(resultQuestion).toHaveLength(5)
|
||||
|
||||
// Both should return Mondays at 9:00
|
||||
resultStar.forEach((date) => {
|
||||
expect(date.getDay()).toBe(1)
|
||||
expect(date.getHours()).toBe(9)
|
||||
})
|
||||
resultQuestion.forEach((date) => {
|
||||
expect(date.getDay()).toBe(1)
|
||||
expect(date.getHours()).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles predefined expressions', () => {
|
||||
const daily = parseCronExpression('@daily')
|
||||
const weekly = parseCronExpression('@weekly')
|
||||
const monthly = parseCronExpression('@monthly')
|
||||
|
||||
expect(daily).toHaveLength(5)
|
||||
expect(weekly).toHaveLength(5)
|
||||
expect(monthly).toHaveLength(5)
|
||||
|
||||
// @daily should be at midnight
|
||||
daily.forEach((date) => {
|
||||
expect(date.getHours()).toBe(0)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
// @weekly should be on Sundays at midnight
|
||||
weekly.forEach((date) => {
|
||||
expect(date.getDay()).toBe(0) // Sunday
|
||||
expect(date.getHours()).toBe(0)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
// @monthly should be on the 1st of each month at midnight
|
||||
monthly.forEach((date) => {
|
||||
expect(date.getDate()).toBe(1)
|
||||
expect(date.getHours()).toBe(0)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
test('handles complex month/day combinations', () => {
|
||||
// Test Feb 29 with month abbreviation
|
||||
const result = parseCronExpression('0 12 29 FEB *')
|
||||
if (result.length > 0) {
|
||||
result.forEach((date) => {
|
||||
expect(date.getMonth()).toBe(1) // February
|
||||
expect(date.getDate()).toBe(29)
|
||||
// Should only occur in leap years
|
||||
const year = date.getFullYear()
|
||||
expect(year % 4).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('handles mixed syntax correctly', () => {
|
||||
// Mix of numbers and abbreviations (using only dayOfMonth OR dayOfWeek, not both)
|
||||
// Test 1: Month abbreviations with specific day
|
||||
const result1 = parseCronExpression('30 14 15 JAN,JUN,DEC *')
|
||||
expect(result1.length).toBeGreaterThan(0)
|
||||
result1.forEach((date) => {
|
||||
expect(date.getDate()).toBe(15) // Should be 15th day
|
||||
expect([0, 5, 11]).toContain(date.getMonth()) // Jan, Jun, Dec
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
// Test 2: Month abbreviations with weekdays
|
||||
const result2 = parseCronExpression('0 9 * JAN-MAR MON-FRI')
|
||||
expect(result2.length).toBeGreaterThan(0)
|
||||
result2.forEach((date) => {
|
||||
// Should be weekday OR in Q1 months
|
||||
const isWeekday = date.getDay() >= 1 && date.getDay() <= 5
|
||||
const isQ1 = [0, 1, 2].includes(date.getMonth())
|
||||
expect(isWeekday || isQ1).toBe(true)
|
||||
expect(date.getHours()).toBe(9)
|
||||
expect(date.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('handles timezone edge cases', () => {
|
||||
// Test with different timezones
|
||||
const utcResult = parseCronExpression('0 12 * * *', 'UTC')
|
||||
const nyResult = parseCronExpression('0 12 * * *', 'America/New_York')
|
||||
const tokyoResult = parseCronExpression('0 12 * * *', 'Asia/Tokyo')
|
||||
|
||||
expect(utcResult).toHaveLength(5)
|
||||
expect(nyResult).toHaveLength(5)
|
||||
expect(tokyoResult).toHaveLength(5)
|
||||
|
||||
// All should be at noon in their respective timezones
|
||||
utcResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
nyResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
tokyoResult.forEach(date => expect(date.getHours()).toBe(12))
|
||||
})
|
||||
|
||||
test('timezone compatibility and DST handling', () => {
|
||||
// Test DST boundary scenarios
|
||||
jest.useFakeTimers()
|
||||
|
||||
try {
|
||||
// Test 1: DST spring forward (March 2024) - America/New_York
|
||||
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
|
||||
const springDST = parseCronExpression('0 2 * * *', 'America/New_York')
|
||||
expect(springDST).toHaveLength(5)
|
||||
springDST.forEach(date => expect([2, 3]).toContain(date.getHours()))
|
||||
|
||||
// Test 2: DST fall back (November 2024) - America/New_York
|
||||
jest.setSystemTime(new Date('2024-11-01T10:00:00Z'))
|
||||
const fallDST = parseCronExpression('0 1 * * *', 'America/New_York')
|
||||
expect(fallDST).toHaveLength(5)
|
||||
fallDST.forEach(date => expect(date.getHours()).toBe(1))
|
||||
|
||||
// Test 3: Cross-timezone consistency on same UTC moment
|
||||
jest.setSystemTime(new Date('2024-06-15T12:00:00Z'))
|
||||
const utcNoon = parseCronExpression('0 12 * * *', 'UTC')
|
||||
const nycMorning = parseCronExpression('0 8 * * *', 'America/New_York') // 8 AM NYC = 12 PM UTC in summer
|
||||
const tokyoEvening = parseCronExpression('0 21 * * *', 'Asia/Tokyo') // 9 PM Tokyo = 12 PM UTC
|
||||
|
||||
expect(utcNoon).toHaveLength(5)
|
||||
expect(nycMorning).toHaveLength(5)
|
||||
expect(tokyoEvening).toHaveLength(5)
|
||||
|
||||
// Verify timezone consistency - all should represent the same UTC moments
|
||||
const utcTime = utcNoon[0]
|
||||
const nycTime = nycMorning[0]
|
||||
const tokyoTime = tokyoEvening[0]
|
||||
|
||||
expect(utcTime.getHours()).toBe(12)
|
||||
expect(nycTime.getHours()).toBe(8)
|
||||
expect(tokyoTime.getHours()).toBe(21)
|
||||
}
|
||||
finally {
|
||||
jest.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test('backward compatibility with execution-time-calculator timezone logic', () => {
|
||||
// Simulate the exact usage pattern from execution-time-calculator.ts:47
|
||||
const mockData = {
|
||||
cron_expression: '30 14 * * 1-5', // 2:30 PM weekdays
|
||||
timezone: 'America/New_York',
|
||||
}
|
||||
|
||||
// This is the exact call from execution-time-calculator.ts
|
||||
const results = parseCronExpression(mockData.cron_expression, mockData.timezone)
|
||||
expect(results).toHaveLength(5)
|
||||
|
||||
results.forEach((date) => {
|
||||
// Should be weekdays (1-5)
|
||||
expect(date.getDay()).toBeGreaterThanOrEqual(1)
|
||||
expect(date.getDay()).toBeLessThanOrEqual(5)
|
||||
|
||||
// Should be 2:30 PM in the user's timezone representation
|
||||
expect(date.getHours()).toBe(14)
|
||||
expect(date.getMinutes()).toBe(30)
|
||||
expect(date.getSeconds()).toBe(0)
|
||||
|
||||
// Should be Date objects (not CronDate or other types)
|
||||
expect(date).toBeInstanceOf(Date)
|
||||
|
||||
// Should be in the future (relative to test time)
|
||||
expect(date.getTime()).toBeGreaterThan(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
test('edge case timezone handling', () => {
|
||||
// Test uncommon but valid timezones
|
||||
const australiaResult = parseCronExpression('0 15 * * *', 'Australia/Sydney')
|
||||
const indiaResult = parseCronExpression('0 9 * * *', 'Asia/Kolkata') // UTC+5:30
|
||||
const alaskaResult = parseCronExpression('0 6 * * *', 'America/Anchorage')
|
||||
|
||||
expect(australiaResult).toHaveLength(5)
|
||||
expect(indiaResult).toHaveLength(5)
|
||||
expect(alaskaResult).toHaveLength(5)
|
||||
|
||||
australiaResult.forEach(date => expect(date.getHours()).toBe(15))
|
||||
indiaResult.forEach(date => expect(date.getHours()).toBe(9))
|
||||
alaskaResult.forEach(date => expect(date.getHours()).toBe(6))
|
||||
|
||||
// Test invalid timezone graceful handling
|
||||
const invalidTzResult = parseCronExpression('0 12 * * *', 'Invalid/Timezone')
|
||||
// Should either return empty array or handle gracefully
|
||||
expect(Array.isArray(invalidTzResult)).toBe(true)
|
||||
})
|
||||
|
||||
test('gracefully handles invalid enhanced syntax', () => {
|
||||
// Invalid but close to valid expressions
|
||||
expect(parseCronExpression('0 12 * JANUARY *')).toEqual([]) // Full month name
|
||||
expect(parseCronExpression('0 12 * * MONDAY')).toEqual([]) // Full day name
|
||||
expect(parseCronExpression('0 12 32 JAN *')).toEqual([]) // Invalid day with valid month
|
||||
expect(parseCronExpression('@invalid')).toEqual([]) // Invalid predefined
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance tests', () => {
|
||||
test('performs well for complex expressions', () => {
|
||||
const start = performance.now()
|
||||
|
||||
// Test multiple complex expressions
|
||||
// Test multiple complex expressions including new syntax
|
||||
const expressions = [
|
||||
'*/5 9-17 * * 1-5', // Every 5 minutes, weekdays, business hours
|
||||
'0 */2 1,15 * *', // Every 2 hours on 1st and 15th
|
||||
'30 14 * * 1,3,5', // Mon, Wed, Fri at 14:30
|
||||
'15,45 8-18 * * 1-5', // 15 and 45 minutes past the hour, weekdays
|
||||
'0 9 * JAN-MAR MON-FRI', // Weekdays in Q1 at 9:00
|
||||
'0 12 ? * SUN', // Sundays at noon using ?
|
||||
'@daily', // Predefined expression
|
||||
'@weekly', // Predefined expression
|
||||
]
|
||||
|
||||
expressions.forEach((expr) => {
|
||||
const result = parseCronExpression(expr)
|
||||
expect(result).toHaveLength(5)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
// Test quarterly expression separately (may return fewer than 5 results)
|
||||
@ -226,8 +513,8 @@ describe('cron-parser', () => {
|
||||
|
||||
const end = performance.now()
|
||||
|
||||
// Should complete within reasonable time (less than 100ms for all expressions)
|
||||
expect(end - start).toBeLessThan(100)
|
||||
// Should complete within reasonable time (less than 150ms for all expressions)
|
||||
expect(end - start).toBeLessThan(150)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user