mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
fix: preserve cron semantics during node-cron adaptation
Preserve supported expression fields instead of stringifying them, and probe individual fields only when whole-expression validation fails. Convert unsupported syntax and weekday ranges ending in 7 while preserving legacy date/weekday rules and valid OR branches. Add execution regressions for issue #3099 and 64 calendar boundary cases in UTC and Asia/Shanghai. Ensure timezone child tests actually run under the Node test runner. Validation: 745 tests passed, 8 skipped; final targeted regressions passed; backend compilation passed. Differential calendar checks matched 142,360 execution timestamps. Fixes #3099
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
const native = require('node-cron');
|
||||
const fixture = require('../fixtures/cron-calendar-boundaries.json');
|
||||
|
||||
// Each deployment gets a fresh timezone formatter cache in node-cron.
|
||||
const timezone = process.env.QL_CRON_BOUNDARY_TEST_TZ;
|
||||
if (!timezone) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`cron calendar boundary regressions in ${tz}`, () => {
|
||||
const env = { ...process.env, TZ: tz, QL_CRON_BOUNDARY_TEST_TZ: tz };
|
||||
// A nested --test runner otherwise exits successfully without running tests.
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /calendar boundary:/);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const { schedule, currentDate, next } of fixture.cases) {
|
||||
test(`calendar boundary: ${schedule} from ${currentDate}`, async (t) => {
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date', 'setTimeout', 'setInterval'],
|
||||
now: new Date(currentDate),
|
||||
});
|
||||
const before = native.getTasks().size;
|
||||
const actual = [];
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
(date) => actual.push(date.toISOString()),
|
||||
{ name: 'calendar-boundary', logger: { warn() {}, error() {} } },
|
||||
);
|
||||
t.after(() => job.cancel());
|
||||
|
||||
for (const [index, expected] of next[timezone].entries()) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next[timezone].slice(0, index));
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next[timezone].slice(0, index + 1));
|
||||
}
|
||||
job.cancel();
|
||||
assert.equal(native.getTasks().size, before);
|
||||
});
|
||||
}
|
||||
|
||||
test('unreachable dates and unsupported L spellings remain invalid', () => {
|
||||
for (const schedule of [
|
||||
'0 0 31 2,4 *',
|
||||
'0 0 31 2,4 0-7',
|
||||
'0 0 1L 2,4 MON',
|
||||
'0 0 * * 05L',
|
||||
]) {
|
||||
assert.equal(isValidCronSchedule(schedule), false, schedule);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,10 +7,13 @@ const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
if (!process.env.QL_MACRO_TEST_TZ) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`macro execution in ${tz}`, () => {
|
||||
const env = { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const result = require('node:child_process').spawnSync(process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
|
||||
{ env, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.match(result.stdout, /@annually fires at the expected local times/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { validate } = require('node-cron');
|
||||
const {
|
||||
parseCronSchedule,
|
||||
isValidCronSchedule,
|
||||
getInvalidCronSchedules,
|
||||
} = require('../../back/shared/cronSchedule');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
|
||||
const logger = { warn() {}, error() {} };
|
||||
|
||||
// Issue #3099: cron-parser 5.4 stringifies these hours as 6/6, which
|
||||
// node-cron rejects. Preserve supported fields and observe actual executions.
|
||||
const cases = [
|
||||
...[
|
||||
'0 6,12,18 * * *',
|
||||
'0 0 6,12,18 * * *',
|
||||
'0 6-18/6 * * *',
|
||||
'0 6/6 * * *',
|
||||
].map((schedule) => ({
|
||||
schedule,
|
||||
next: [
|
||||
'2026-10-17T06:00:00.000Z',
|
||||
'2026-10-17T12:00:00.000Z',
|
||||
'2026-10-17T18:00:00.000Z',
|
||||
'2026-10-18T06:00:00.000Z',
|
||||
],
|
||||
})),
|
||||
{
|
||||
schedule: '5,15,25 * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:05:00.000Z',
|
||||
'2026-10-17T00:15:00.000Z',
|
||||
'2026-10-17T00:25:00.000Z',
|
||||
'2026-10-17T01:05:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5,15,25 * * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:00:05.000Z',
|
||||
'2026-10-17T00:00:15.000Z',
|
||||
'2026-10-17T00:00:25.000Z',
|
||||
'2026-10-17T00:01:05.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5/20 * * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:00:05.000Z',
|
||||
'2026-10-17T00:00:25.000Z',
|
||||
'2026-10-17T00:00:45.000Z',
|
||||
'2026-10-17T00:01:05.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '5/20 * * * *',
|
||||
next: [
|
||||
'2026-10-17T00:05:00.000Z',
|
||||
'2026-10-17T00:25:00.000Z',
|
||||
'2026-10-17T00:45:00.000Z',
|
||||
'2026-10-17T01:05:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 1/10,L * MON',
|
||||
next: [
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
'2026-10-26T00:00:00.000Z',
|
||||
'2026-10-31T00:00:00.000Z',
|
||||
'2026-11-01T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 1 JAN/3 *',
|
||||
next: [
|
||||
'2027-01-01T00:00:00.000Z',
|
||||
'2027-04-01T00:00:00.000Z',
|
||||
'2027-07-01T00:00:00.000Z',
|
||||
'2027-10-01T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 * * MON/2',
|
||||
next: [
|
||||
'2026-10-18T00:00:00.000Z',
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
'2026-10-23T00:00:00.000Z',
|
||||
'2026-10-25T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
schedule: '0 0 * * 00#02',
|
||||
next: [
|
||||
'2026-11-08T00:00:00.000Z',
|
||||
'2026-12-13T00:00:00.000Z',
|
||||
'2027-01-10T00:00:00.000Z',
|
||||
'2027-02-14T00:00:00.000Z',
|
||||
],
|
||||
},
|
||||
...['*#2', '?#2'].map((weekday) => ({
|
||||
schedule: `0 0 * * ${weekday}`,
|
||||
next: [
|
||||
'2026-10-18T00:00:00.000Z',
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
'2026-10-20T00:00:00.000Z',
|
||||
'2026-10-21T00:00:00.000Z',
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
test('supported fields retain their lists, ranges, names and special days', () => {
|
||||
for (const [schedule, patterns] of [
|
||||
['0 6,12,18 * * *', ['0 0 6,12,18 * * *']],
|
||||
['5,15,25 * * JAN-MAR MON-FRI', ['0 5,15,25 * * JAN-MAR MON-FRI']],
|
||||
['0 0 L JAN *', ['0 0 0 L JAN *']],
|
||||
['0 0 * JAN MON#2', ['0 0 0 * JAN MON#2']],
|
||||
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON']],
|
||||
['0 0 ? * MON', ['0 0 0 * * MON']],
|
||||
]) {
|
||||
assert.deepEqual(parseCronSchedule(schedule).patterns, patterns, schedule);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [schedule, patterns, maxValidations] of [
|
||||
['0 6,12,18 * * *', ['0 0 6,12,18 * * *'], 2],
|
||||
['0 0 1,15 JAN MON', ['0 0 0 1,15 JAN *', '0 0 0 * JAN MON'], 3],
|
||||
]) {
|
||||
test(`supported expressions skip field validation: ${schedule}`, () => {
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const validations = [];
|
||||
const isolated = load('back/shared/cronSchedule.ts', {
|
||||
'node-cron': {
|
||||
validate(pattern) {
|
||||
validations.push(pattern);
|
||||
return validate(pattern);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(isolated.parseCronSchedule(schedule).patterns, patterns);
|
||||
assert.ok(validations.length <= maxValidations, validations.join('\n'));
|
||||
assert.equal(validations[0], `0 ${schedule}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const { schedule, next } of cases) {
|
||||
test(`cron-parser normalization preserves executions: ${schedule}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
t.after(() => {
|
||||
if (previousTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = previousTZ;
|
||||
});
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date', 'setTimeout', 'setInterval'],
|
||||
now: new Date('2026-10-17T00:00:00Z'),
|
||||
});
|
||||
|
||||
assert.equal(isValidCronSchedule(schedule), true);
|
||||
assert.ok(parseCronSchedule(schedule).patterns.every(validate));
|
||||
assert.deepEqual(
|
||||
getInvalidCronSchedules({
|
||||
schedule,
|
||||
extra_schedules: [{ schedule }],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const actual = [];
|
||||
const job = createCronJob(
|
||||
schedule,
|
||||
(date) => actual.push(date.toISOString()),
|
||||
{ name: 'issue-3099', logger },
|
||||
);
|
||||
t.after(() => job.cancel());
|
||||
for (const [index, expected] of next.entries()) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next.slice(0, index));
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(actual, next.slice(0, index + 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
+1168
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user