Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/conformance/server-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {checkSdkTimerAuthorArtifacts, checkSdkTimerAuthorPackages} from './serve
import {checkSdkSignalDirectionArtifacts, checkSdkSignalDirectionPackages} from './server-parity/sdk-signal-direction-contract.mjs';
import {captureProbe, probeNotes} from './server-parity/probe-capture.mjs';
import {selectFixturePaths} from './server-parity/fixture-selection.mjs';
import {waitOutScheduleOccurrences} from './server-parity/schedule-occurrence-guard.mjs';
import {writeFixtureSummary, writeRecord} from './server-parity/record-file.mjs';
import {checkCorrectedMarkerArtifacts, checkCorrectedMarkerPackages} from './server-parity/corrected-marker-contract.mjs';
import {checkCorrectedRangeArtifacts, checkCorrectedRangePackages} from './server-parity/corrected-range-contract.mjs';
Expand Down Expand Up @@ -106,6 +107,7 @@ try {
checkSdkTimerAuthorArtifacts(fixture, record.artifacts);
checkSdkSignalDirectionArtifacts(fixture, record.artifacts);
if (fixture.patch_deployment?.legacy_marker_history) checkLegacyMarkerArtifacts(fixture, record.artifacts);
if (fixture.schedule_catalog) waitOutScheduleOccurrences(fixture.id);
const workflowId = `${values.prefix}-${fixture.id}`;
const arguments_ = [resolve(root, 'scripts/conformance/server-parity/probe.php'), '--mode', values.mode, '--fixture', path, '--workflow-id', workflowId];
for (const flag of ['url', 'application-root']) {
Expand Down
58 changes: 58 additions & 0 deletions scripts/conformance/server-parity/schedule-occurrence-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Server #1102: the schedule-catalog fixture keeps live, unpaused schedules
// with real daily occurrences. A recording that spans one lets the target's
// maintenance evaluator fire a schedule, and every response then differs from
// the reviewed expectation. Before that fixture records, wait out any of its
// natural occurrences that falls inside the recording window.

// Each schedule's daily occurrence as a local wall time in its zone
// (tests/Fixtures/ServerParity/schedule-catalog.json): P1D intervals at
// 00:00 UTC, bf's PT6H offset at 06:00 UTC, alpha's `0 4 * * *` in
// America/New_York and bf's `0 3 * * *` in Europe/Budapest.
export const SCHEDULE_CATALOG_OCCURRENCES = [
{zone: 'UTC', hour: 0},
{zone: 'UTC', hour: 6},
{zone: 'America/New_York', hour: 4},
{zone: 'Europe/Budapest', hour: 3},
];
export const RECORDING_WINDOW_MS = 10 * 60 * 1000;
export const MARGIN_MS = 60 * 1000;

// The zone's UTC offset in milliseconds at `instant`.
function offsetMs(zone, instant) {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {timeZone: zone, hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'})
.formatToParts(new Date(instant)).filter(part => part.type !== 'literal').map(part => [part.type, Number(part.value)]));
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - instant;
}

// The UTC instant of `hour`:00 local time in `zone` on the given UTC date.
function occurrenceOn(zone, hour, year, month, day) {
const guess = Date.UTC(year, month, day, hour);
return guess - offsetMs(zone, guess - offsetMs(zone, guess));
}

// Milliseconds to wait before recording at `now`, 0 when no occurrence falls
// within the window.
export function scheduleCatalogWaitMs(now, occurrences = SCHEDULE_CATALOG_OCCURRENCES, windowMs = RECORDING_WINDOW_MS) {
let wait = 0;
for (let delta = -1; delta <= 1; delta++) {
const date = new Date(now + delta * 86400000);
for (const {zone, hour} of occurrences) {
const at = occurrenceOn(zone, hour, date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
if (at + MARGIN_MS > now && at <= now + windowMs) {
wait = Math.max(wait, at + MARGIN_MS - now);
}
}
}
return wait;
}

// Block until no occurrence falls within the window, and say why.
export function waitOutScheduleOccurrences(label, now = Date.now()) {
const wait = scheduleCatalogWaitMs(now);
if (wait > 0) {
console.error(`${label}: waiting ${Math.ceil(wait / 1000)}s for a schedule occurrence to pass before recording (Server #1102)`);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
}
return wait;
}
29 changes: 29 additions & 0 deletions tests/Unit/ServerParityScheduleOccurrenceGuardTest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {scheduleCatalogWaitMs, MARGIN_MS} from '../../scripts/conformance/server-parity/schedule-occurrence-guard.mjs';

const at = iso => Date.parse(iso);

test('a recording that would cross the observed 09:00 UTC alpha occurrence waits it out (EST)', () => {
// #1102: alpha's 04:00 America/New_York fired at 09:00:00Z on 2026-11-08.
const now = at('2026-11-08T08:55:00Z');
assert.equal(scheduleCatalogWaitMs(now), at('2026-11-08T09:00:00Z') + MARGIN_MS - now);
});

test('daylight saving moves the New York and Budapest occurrences', () => {
assert.equal(scheduleCatalogWaitMs(at('2026-07-08T07:55:00Z')), 6 * 60 * 1000); // 04:00 EDT = 08:00Z
assert.equal(scheduleCatalogWaitMs(at('2026-07-08T00:55:00Z')), 6 * 60 * 1000); // 03:00 CEST = 01:00Z
assert.equal(scheduleCatalogWaitMs(at('2026-11-08T01:55:00Z')), 6 * 60 * 1000); // 03:00 CET = 02:00Z
});

test('UTC interval occurrences and the margin after them', () => {
assert.equal(scheduleCatalogWaitMs(at('2026-11-08T23:58:00Z')), 3 * 60 * 1000);
assert.equal(scheduleCatalogWaitMs(at('2026-11-08T05:59:30Z')), 90 * 1000);
// Just past an occurrence, within the margin, still waits for the margin.
assert.equal(scheduleCatalogWaitMs(at('2026-11-09T00:00:30Z')), 30 * 1000);
});

test('a recording far from every occurrence starts at once', () => {
assert.equal(scheduleCatalogWaitMs(at('2026-11-08T12:00:00Z')), 0);
assert.equal(scheduleCatalogWaitMs(at('2026-11-08T07:11:00Z')), 0);
});
Loading