@stonyx/cron 0.2.0 → 0.2.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ # Testing Guidelines
2
+
3
+ ## Testing Guidelines
4
+
5
+ ### Test Structure
6
+ Tests are located in `stonyx-cron/test/unit/` and use QUnit modules:
7
+
8
+ ```javascript
9
+ import QUnit from 'qunit';
10
+ import sinon from 'sinon';
11
+ import { setupIntegrationTests } from "stonyx/test-helpers";
12
+
13
+ const { module, test } = QUnit;
14
+
15
+ module('[Unit] Cron', function (hooks) {
16
+ setupIntegrationTests(hooks);
17
+
18
+ let cron, clock;
19
+
20
+ hooks.beforeEach(function () {
21
+ clock = sinon.useFakeTimers({ shouldAdvanceTime: false });
22
+ cron = new Cron();
23
+ });
24
+
25
+ hooks.afterEach(function () {
26
+ sinon.restore();
27
+ });
28
+
29
+ test('test description', async function (assert) {
30
+ // Test implementation
31
+ });
32
+ });
33
+ ```
34
+
35
+ ### Fake Timers Pattern (CRITICAL)
36
+ **Always use fake timers for time-based tests:**
37
+
38
+ ```javascript
39
+ // Setup in beforeEach
40
+ clock = sinon.useFakeTimers({ shouldAdvanceTime: false });
41
+
42
+ // Advance time synchronously
43
+ clock.tick(5000); // Advance 5 seconds
44
+
45
+ // For async operations, use tickAsync
46
+ clock.tick(5000);
47
+ await clock.tickAsync(0); // Process async callbacks
48
+
49
+ // Cleanup in afterEach
50
+ sinon.restore();
51
+ ```
52
+
53
+ **Why `shouldAdvanceTime: false`?**
54
+ Prevents real time from interfering with fake time, ensuring deterministic tests.
55
+
56
+ ### Spies & Stubs Patterns
57
+ ```javascript
58
+ // Spy on function calls
59
+ const cb = sinon.spy();
60
+ cron.register('job1', cb, 5);
61
+ assert.ok(cb.calledOnce, 'Callback executed once');
62
+
63
+ // Stub methods
64
+ const stub = sinon.stub().rejects(new Error('boom'));
65
+ cron.register('jobErr', stub, 1);
66
+
67
+ // Spy on existing methods
68
+ const logSpy = sinon.spy(log, 'cron');
69
+ cron.log('test message');
70
+ assert.ok(logSpy.calledOnce, 'Log called');
71
+ ```
72
+
73
+ ### Test Coverage Expectations
74
+ Tests should cover:
75
+ - Job registration and execution
76
+ - Rescheduling behavior
77
+ - Unregistration
78
+ - Error handling
79
+ - Configuration-driven logging
80
+ - Edge cases (empty heap, multiple jobs, etc.)
81
+
82
+ ### Running Tests
83
+ ```bash
84
+ pnpm test # Runs: stonyx test
85
+ ```
@@ -2,35 +2,15 @@ name: CI
2
2
 
3
3
  on:
4
4
  pull_request:
5
- branches:
6
- - dev
7
- - main
5
+ branches: [dev, main]
8
6
 
9
7
  concurrency:
10
8
  group: ci-${{ github.head_ref || github.ref }}
11
9
  cancel-in-progress: true
12
10
 
11
+ permissions:
12
+ contents: read
13
+
13
14
  jobs:
14
15
  test:
15
- runs-on: ubuntu-latest
16
-
17
- steps:
18
- - name: Checkout code
19
- uses: actions/checkout@v3
20
-
21
- - name: Setup pnpm
22
- uses: pnpm/action-setup@v4
23
- with:
24
- version: 9
25
-
26
- - name: Set up Node.js
27
- uses: actions/setup-node@v3
28
- with:
29
- node-version: 22.18.0
30
- cache: 'pnpm'
31
-
32
- - name: Install dependencies
33
- run: pnpm install --frozen-lockfile
34
-
35
- - name: Run tests
36
- run: pnpm test
16
+ uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
@@ -0,0 +1,51 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ repository_dispatch:
5
+ types: [cascade-publish]
6
+ workflow_dispatch:
7
+ inputs:
8
+ version-type:
9
+ description: 'Version type'
10
+ required: true
11
+ type: choice
12
+ options:
13
+ - patch
14
+ - minor
15
+ - major
16
+ custom-version:
17
+ description: 'Custom version (optional, overrides version-type)'
18
+ required: false
19
+ type: string
20
+ pull_request:
21
+ types: [opened, synchronize, reopened]
22
+ branches: [main]
23
+ push:
24
+ branches: [main]
25
+
26
+ concurrency:
27
+ group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
28
+ cancel-in-progress: false
29
+
30
+ permissions:
31
+ contents: write
32
+ id-token: write
33
+ pull-requests: write
34
+
35
+ jobs:
36
+ publish:
37
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
38
+ uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
39
+ with:
40
+ version-type: ${{ github.event.inputs.version-type }}
41
+ custom-version: ${{ github.event.inputs.custom-version }}
42
+ cascade-source: ${{ github.event.client_payload.source_package || '' }}
43
+ secrets: inherit
44
+
45
+ cascade:
46
+ needs: publish
47
+ uses: abofs/stonyx-workflows/.github/workflows/cascade.yml@main
48
+ with:
49
+ package-name: ${{ needs.publish.outputs.package-name }}
50
+ published-version: ${{ needs.publish.outputs.published-version }}
51
+ secrets: inherit
package/.gitignore CHANGED
@@ -11,3 +11,6 @@ config/environment.js
11
11
  .vscode/*
12
12
  *.vsix%
13
13
  *DS_Store*
14
+
15
+ # npm auth (never commit tokens)
16
+ .npmrc
package/.npmignore CHANGED
@@ -2,4 +2,6 @@
2
2
  test/
3
3
 
4
4
  # config
5
- .nvmrc
5
+ .nvmrc
6
+ # git
7
+ .git
package/README.md CHANGED
@@ -20,19 +20,18 @@ cron.register('exampleJob', async () => {
20
20
 
21
21
  ## How it works
22
22
 
23
- `stonyx-cron` uses a min-heap internally to efficiently track the next job to run. Each job has a scheduled trigger time, and the heap ensures the job with the earliest trigger is always at the top.
23
+ `stonyx-cron` uses a min-heap internally to efficiently track the next job to run. Each job has a scheduled trigger time, and the heap ensures the job with the earliest trigger is always at the top.
24
24
 
25
25
  When a job is executed, its next trigger time is updated, and it is re-inserted into the heap. This allows `Cron` to always know which job should run next without scanning all jobs, keeping scheduling efficient even with many jobs.
26
26
 
27
-
28
- ### Public Methods
27
+ ## Public Methods
29
28
 
30
29
  | Method | Parameters | Description |
31
30
  | :----------: | :----------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------- |
32
31
  | `register` | `key: string, callback: Function, interval: number, runOnInit?: boolean` | Register a new job with a given interval in seconds. If `runOnInit` is true, the job runs immediately upon registration. |
33
32
  | `unregister` | `key: string` | Remove a previously registered job. |
34
33
 
35
- > All other methods and classes (like `MinHeap`) are used internally by `Cron` and are not intended for direct use.
34
+ > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
36
35
 
37
36
  ## Configuration
38
37
 
package/logs/error.log CHANGED
@@ -1 +1,2 @@
1
- [12/31/1969, 7:00:01 PM] Cron job "jobErr" failed:
1
+ [1/1/1970, 12:00:01 AM] Cron job "jobErr" failed:
2
+ [1/1/1970, 12:00:01 AM] Cron job "jobErr" failed:
package/package.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.0",
7
- "description": "",
6
+ "version": "0.2.1-alpha.1",
7
+ "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "src/main.js",
9
9
  "type": "module",
10
10
  "files": [
@@ -12,8 +12,19 @@
12
12
  ],
13
13
  "exports": {
14
14
  ".": "./src/main.js",
15
+ "./service": "./src/service.js",
16
+ "./cron-parser": "./src/cron-parser.js",
17
+ "./schedule": "./src/schedule.js",
18
+ "./job": "./src/job.js",
19
+ "./normalize": "./src/normalize.js",
20
+ "./locked": "./src/locked.js",
21
+ "./run-log": "./src/run-log.js",
15
22
  "./min-heap": "./src/min-heap.js"
16
23
  },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
17
28
  "repository": {
18
29
  "type": "git",
19
30
  "url": "git+https://github.com/abofs/stonyx-cron.git"
@@ -28,14 +39,14 @@
28
39
  },
29
40
  "homepage": "https://github.com/abofs/stonyx-cron#readme",
30
41
  "devDependencies": {
31
- "@stonyx/utils": "^0.2.2",
42
+ "@stonyx/utils": "0.2.3-beta.7",
32
43
  "qunit": "^2.24.1",
33
44
  "sinon": "^21.0.0"
34
45
  },
35
46
  "dependencies": {
36
- "stonyx": "^0.2.2"
47
+ "stonyx": "0.2.3-beta.11"
37
48
  },
38
49
  "scripts": {
39
- "test": "qunit --require ./stonyx-bootstrap.cjs"
50
+ "test": "stonyx test"
40
51
  }
41
52
  }
package/pnpm-lock.yaml CHANGED
@@ -9,12 +9,12 @@ importers:
9
9
  .:
10
10
  dependencies:
11
11
  stonyx:
12
- specifier: ^0.2.2
13
- version: 0.2.2
12
+ specifier: 0.2.3-beta.11
13
+ version: 0.2.3-beta.11
14
14
  devDependencies:
15
15
  '@stonyx/utils':
16
- specifier: ^0.2.2
17
- version: 0.2.2
16
+ specifier: 0.2.3-beta.7
17
+ version: 0.2.3-beta.7
18
18
  qunit:
19
19
  specifier: ^2.24.1
20
20
  version: 2.25.0
@@ -33,8 +33,8 @@ packages:
33
33
  '@sinonjs/samsam@8.0.3':
34
34
  resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==}
35
35
 
36
- '@stonyx/utils@0.2.2':
37
- resolution: {integrity: sha512-z/pDbX3QPeVJ3kFpU1rQURj3+BLwvGkLHVJiSckmjaBZ/vQIZzaerkf6BTZnHbSmQuUwO0lFzJRwlKOTIaXp0g==}
36
+ '@stonyx/utils@0.2.3-beta.7':
37
+ resolution: {integrity: sha512-SF6ZZZJ/f1n/+SJJDj8BJdlgvv+WDLGWGUMIkwTpruA5jv/AYJqSoVIgTpYfuMTnYDIsp3KoruaIKy/qd2ETPQ==}
38
38
 
39
39
  call-bind-apply-helpers@1.0.2:
40
40
  resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
@@ -164,8 +164,9 @@ packages:
164
164
  sinon@21.0.1:
165
165
  resolution: {integrity: sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==}
166
166
 
167
- stonyx@0.2.2:
168
- resolution: {integrity: sha512-X37/2R2YklI+CX4wKHckPg2pFmOL1hk0CwOMvn7E00sE+8JjhmbHvAeTI+0t50kufBsXKREPX3FBHVUd4Yi4Fw==}
167
+ stonyx@0.2.3-beta.11:
168
+ resolution: {integrity: sha512-HqDH7/8q7JeuTOdlekY8BGAM8FGHWTLKPWhd3xC6dAYBvK1zQWJrzINbIIymemReBxy/sDw3cs7PG8kcoOK1dg==}
169
+ hasBin: true
169
170
 
170
171
  supports-color@7.2.0:
171
172
  resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@@ -204,7 +205,7 @@ snapshots:
204
205
  '@sinonjs/commons': 3.0.1
205
206
  type-detect: 4.1.0
206
207
 
207
- '@stonyx/utils@0.2.2': {}
208
+ '@stonyx/utils@0.2.3-beta.7': {}
208
209
 
209
210
  call-bind-apply-helpers@1.0.2:
210
211
  dependencies:
@@ -342,7 +343,7 @@ snapshots:
342
343
  diff: 8.0.3
343
344
  supports-color: 7.2.0
344
345
 
345
- stonyx@0.2.2:
346
+ stonyx@0.2.3-beta.11:
346
347
  dependencies:
347
348
  node-chronicle: 0.2.0
348
349
 
@@ -0,0 +1,246 @@
1
+ /**
2
+ * 5-field cron expression parser with next-occurrence computation.
3
+ * No external dependencies — built for stonyx-cron.
4
+ *
5
+ * Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
6
+ * Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
7
+ */
8
+
9
+ const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
10
+ const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
11
+
12
+ const FIELD_RANGES = [
13
+ { min: 0, max: 59 }, // minute
14
+ { min: 0, max: 23 }, // hour
15
+ { min: 1, max: 31 }, // day of month
16
+ { min: 1, max: 12 }, // month
17
+ { min: 0, max: 6 }, // day of week
18
+ ];
19
+
20
+ /**
21
+ * Parse a single cron field into a sorted array of allowed values.
22
+ * @param {string} field - The field string (e.g., "1-5", "* /15", "mon,wed,fri")
23
+ * @param {number} fieldIndex - Index (0=minute, 1=hour, 2=dom, 3=month, 4=dow)
24
+ * @returns {number[]} Sorted array of allowed integer values
25
+ */
26
+ export function parseField(field, fieldIndex) {
27
+ const { min, max } = FIELD_RANGES[fieldIndex];
28
+ const names = fieldIndex === 3 ? MONTH_NAMES : fieldIndex === 4 ? DAY_NAMES : null;
29
+
30
+ const resolveToken = (token) => {
31
+ if (names) {
32
+ const lower = token.toLowerCase();
33
+ if (lower in names) return names[lower];
34
+ }
35
+ const n = Number(token);
36
+ if (!Number.isInteger(n)) throw new Error(`Invalid cron value: "${token}" in field ${fieldIndex}`);
37
+ // Normalize day-of-week 7 → 0 (both mean Sunday)
38
+ if (fieldIndex === 4 && n === 7) return 0;
39
+ return n;
40
+ };
41
+
42
+ const results = new Set();
43
+
44
+ for (const part of field.split(',')) {
45
+ const trimmed = part.trim();
46
+ const [rangeStr, stepStr] = trimmed.split('/');
47
+ const step = stepStr !== undefined ? Number(stepStr) : 1;
48
+
49
+ if (!Number.isInteger(step) || step < 1) {
50
+ throw new Error(`Invalid step "${stepStr}" in cron field ${fieldIndex}`);
51
+ }
52
+
53
+ let start, end;
54
+
55
+ if (rangeStr === '*') {
56
+ start = min;
57
+ end = max;
58
+ } else if (rangeStr.includes('-')) {
59
+ const [lo, hi] = rangeStr.split('-');
60
+ start = resolveToken(lo);
61
+ end = resolveToken(hi);
62
+ } else {
63
+ start = resolveToken(rangeStr);
64
+ end = stepStr !== undefined ? max : start;
65
+ }
66
+
67
+ if (start < min || start > max || end < min || end > max) {
68
+ throw new Error(`Value out of range [${min}-${max}] in cron field ${fieldIndex}: "${trimmed}"`);
69
+ }
70
+
71
+ for (let v = start; v <= end; v += step) {
72
+ results.add(v);
73
+ }
74
+ }
75
+
76
+ return [...results].sort((a, b) => a - b);
77
+ }
78
+
79
+ /**
80
+ * Parse a 5-field cron expression into field arrays.
81
+ * @param {string} expr - Cron expression (e.g., "0 9 * * 1-5")
82
+ * @returns {{ minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[] }}
83
+ */
84
+ export function parseCronExpression(expr) {
85
+ const fields = expr.trim().split(/\s+/);
86
+ if (fields.length !== 5) {
87
+ throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
88
+ }
89
+
90
+ return {
91
+ minutes: parseField(fields[0], 0),
92
+ hours: parseField(fields[1], 1),
93
+ daysOfMonth: parseField(fields[2], 2),
94
+ months: parseField(fields[3], 3),
95
+ daysOfWeek: parseField(fields[4], 4),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Get the number of days in a given month/year.
101
+ */
102
+ function daysInMonth(year, month) {
103
+ return new Date(year, month, 0).getDate();
104
+ }
105
+
106
+ /**
107
+ * Check if a day-of-month + day-of-week pair matches the parsed expression.
108
+ *
109
+ * Standard cron behavior: if BOTH dom and dow are restricted (not *),
110
+ * then EITHER matching is sufficient (OR logic).
111
+ * If only one is restricted, it acts as the sole filter.
112
+ */
113
+ function dayMatches(parsed, domWild, dowWild, dayOfMonth, dayOfWeek) {
114
+ const domMatch = parsed.daysOfMonth.includes(dayOfMonth);
115
+ const dowMatch = parsed.daysOfWeek.includes(dayOfWeek);
116
+
117
+ if (domWild && dowWild) return true;
118
+ if (domWild) return dowMatch;
119
+ if (dowWild) return domMatch;
120
+ return domMatch || dowMatch; // Both restricted → OR
121
+ }
122
+
123
+ /**
124
+ * Compute the next occurrence of a cron expression after a given timestamp.
125
+ *
126
+ * @param {string} expr - 5-field cron expression
127
+ * @param {number} afterMs - Timestamp in milliseconds (exclusive — finds strictly after this)
128
+ * @param {string} [tz] - IANA timezone (defaults to system timezone)
129
+ * @returns {number|undefined} Next occurrence in milliseconds, or undefined if none within 4 years
130
+ */
131
+ export function nextOccurrence(expr, afterMs, tz) {
132
+ const parsed = parseCronExpression(expr);
133
+ const exprFields = expr.trim().split(/\s+/);
134
+ const domWild = exprFields[2] === '*';
135
+ const dowWild = exprFields[4] === '*';
136
+
137
+ // Start from the next whole minute after afterMs
138
+ const startDate = new Date(afterMs);
139
+ startDate.setSeconds(0, 0);
140
+ startDate.setMinutes(startDate.getMinutes() + 1);
141
+
142
+ // Convert to target timezone for field matching
143
+ const formatter = new Intl.DateTimeFormat('en-US', {
144
+ timeZone: tz || undefined,
145
+ year: 'numeric', month: 'numeric', day: 'numeric',
146
+ hour: 'numeric', minute: 'numeric', hour12: false,
147
+ weekday: 'short',
148
+ });
149
+
150
+ const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
151
+
152
+ // Parse formatted date parts in the target timezone
153
+ function getLocalParts(date) {
154
+ const parts = {};
155
+ for (const { type, value } of formatter.formatToParts(date)) {
156
+ parts[type] = value;
157
+ }
158
+ return {
159
+ year: Number(parts.year),
160
+ month: Number(parts.month),
161
+ day: Number(parts.day),
162
+ hour: Number(parts.hour === '24' ? 0 : parts.hour),
163
+ minute: Number(parts.minute),
164
+ weekday: dayMap[parts.weekday] ?? 0,
165
+ };
166
+ }
167
+
168
+ // Search limit: 4 years of minutes (≈ 2.1M iterations max)
169
+ const maxMs = afterMs + 4 * 365.25 * 24 * 60 * 60 * 1000;
170
+ let candidate = new Date(startDate);
171
+
172
+ while (candidate.getTime() <= maxMs) {
173
+ const p = getLocalParts(candidate);
174
+
175
+ // Check month
176
+ if (!parsed.months.includes(p.month)) {
177
+ // Advance to next matching month
178
+ const nextMonth = parsed.months.find(m => m > p.month);
179
+ if (nextMonth) {
180
+ // Stay in same year, advance to first day of nextMonth
181
+ candidate = advanceToMonth(candidate, p.year, nextMonth, tz, formatter, dayMap);
182
+ } else {
183
+ // Wrap to next year, first matching month
184
+ candidate = advanceToMonth(candidate, p.year + 1, parsed.months[0], tz, formatter, dayMap);
185
+ }
186
+ continue;
187
+ }
188
+
189
+ // Check day (dom + dow)
190
+ if (!dayMatches(parsed, domWild, dowWild, p.day, p.weekday)) {
191
+ candidate.setMinutes(candidate.getMinutes() + (24 * 60 - p.hour * 60 - p.minute));
192
+ continue;
193
+ }
194
+
195
+ // Check hour
196
+ if (!parsed.hours.includes(p.hour)) {
197
+ const nextHour = parsed.hours.find(h => h > p.hour);
198
+ if (nextHour) {
199
+ candidate.setMinutes(candidate.getMinutes() + ((nextHour - p.hour) * 60 - p.minute));
200
+ } else {
201
+ // Advance to next day
202
+ candidate.setMinutes(candidate.getMinutes() + ((24 - p.hour) * 60 - p.minute));
203
+ }
204
+ continue;
205
+ }
206
+
207
+ // Check minute
208
+ if (!parsed.minutes.includes(p.minute)) {
209
+ const nextMin = parsed.minutes.find(m => m > p.minute);
210
+ if (nextMin) {
211
+ candidate.setMinutes(candidate.getMinutes() + (nextMin - p.minute));
212
+ } else {
213
+ // Advance to next hour
214
+ candidate.setMinutes(candidate.getMinutes() + (60 - p.minute));
215
+ }
216
+ continue;
217
+ }
218
+
219
+ // All fields match
220
+ return candidate.getTime();
221
+ }
222
+
223
+ return undefined;
224
+ }
225
+
226
+ /**
227
+ * Create a Date advanced to the start of a specific month in a specific year,
228
+ * using the target timezone's midnight.
229
+ */
230
+ function advanceToMonth(current, year, month, tz, formatter, dayMap) {
231
+ // Create a new date at ~start of the target month in UTC, then adjust
232
+ const d = new Date(current);
233
+ // Jump to approximately the right time
234
+ d.setFullYear(year, month - 1, 1);
235
+ d.setHours(0, 0, 0, 0);
236
+ return d;
237
+ }
238
+
239
+ /**
240
+ * Validate a cron expression without computing next occurrence.
241
+ * @param {string} expr - 5-field cron expression
242
+ * @throws {Error} if the expression is invalid
243
+ */
244
+ export function validateCronExpression(expr) {
245
+ parseCronExpression(expr);
246
+ }