fix(FN-0000): stabilize automation cron scheduling and SSE coverage
This commit is contained in:
5
.changeset/fix-dashboard-sse-and-cron-utc.md
Normal file
5
.changeset/fix-dashboard-sse-and-cron-utc.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prevent stale dashboard service workers from trapping old client bundles, and compute automation cron schedules against UTC so monthly runs stay on day 1 across timezones.
|
||||||
5
.changeset/fix-sse-refresh-leak.md
Normal file
5
.changeset/fix-sse-refresh-leak.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dashboard SSE cleanup on browser refresh so stale event streams do not exhaust per-origin browser connections.
|
||||||
@@ -81,6 +81,12 @@ describe("AutomationStore", () => {
|
|||||||
expect(new Date(next).getUTCHours()).toBe(13);
|
expect(new Date(next).getUTCHours()).toBe(13);
|
||||||
expect(new Date(next).getUTCMinutes()).toBe(0);
|
expect(new Date(next).getUTCMinutes()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("computes monthly runs against UTC instead of local machine time", () => {
|
||||||
|
const fromDate = new Date("2026-04-15T00:00:00Z");
|
||||||
|
const next = store.computeNextRun("0 0 1 * *", fromDate);
|
||||||
|
expect(next).toBe("2026-05-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── createSchedule ────────────────────────────────────────────────
|
// ── createSchedule ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
|||||||
import type { ScheduleType } from "./automation.js";
|
import type { ScheduleType } from "./automation.js";
|
||||||
import { Database, fromJson } from "./db.js";
|
import { Database, fromJson } from "./db.js";
|
||||||
|
|
||||||
|
const CRON_TIMEZONE = "UTC";
|
||||||
|
|
||||||
export interface AutomationStoreEvents {
|
export interface AutomationStoreEvents {
|
||||||
"schedule:created": [schedule: ScheduledTask];
|
"schedule:created": [schedule: ScheduledTask];
|
||||||
"schedule:updated": [schedule: ScheduledTask];
|
"schedule:updated": [schedule: ScheduledTask];
|
||||||
@@ -170,6 +172,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
|||||||
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: fromDate ?? new Date(),
|
currentDate: fromDate ?? new Date(),
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
const next = interval.next();
|
const next = interval.next();
|
||||||
return next.toISOString() ?? new Date(next.getTime()).toISOString();
|
return next.toISOString() ?? new Date(next.getTime()).toISOString();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const allScheduleTypesRecord: Record<ScheduleType, true> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allScheduleTypes = Object.keys(allScheduleTypesRecord) as ScheduleType[];
|
const allScheduleTypes = Object.keys(allScheduleTypesRecord) as ScheduleType[];
|
||||||
|
const CRON_TIMEZONE = "UTC";
|
||||||
|
|
||||||
function cronDateToDate(value: { toISOString(): string | null; getTime(): number }): Date {
|
function cronDateToDate(value: { toISOString(): string | null; getTime(): number }): Date {
|
||||||
const iso = value.toISOString();
|
const iso = value.toISOString();
|
||||||
@@ -50,6 +51,7 @@ function cronDateToDate(value: { toISOString(): string | null; getTime(): number
|
|||||||
function parseNextRun(cronExpression: string, currentDate?: Date): Date {
|
function parseNextRun(cronExpression: string, currentDate?: Date): Date {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: currentDate ?? new Date(),
|
currentDate: currentDate ?? new Date(),
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
return cronDateToDate(interval.next());
|
return cronDateToDate(interval.next());
|
||||||
}
|
}
|
||||||
@@ -287,6 +289,7 @@ describe("Preset cron expression edge cases", () => {
|
|||||||
it("ensures weekdays preset never schedules Saturday or Sunday in the next 7 runs", () => {
|
it("ensures weekdays preset never schedules Saturday or Sunday in the next 7 runs", () => {
|
||||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.weekdays, {
|
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.weekdays, {
|
||||||
currentDate: new Date("2026-04-06T00:00:00.000Z"), // Monday
|
currentDate: new Date("2026-04-06T00:00:00.000Z"), // Monday
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const days = Array.from({ length: 7 }, () => cronDateToDate(interval.next()).getUTCDay());
|
const days = Array.from({ length: 7 }, () => cronDateToDate(interval.next()).getUTCDay());
|
||||||
@@ -301,6 +304,7 @@ describe("Preset cron expression edge cases", () => {
|
|||||||
it("ensures every15Minutes preset advances in 15 minute intervals", () => {
|
it("ensures every15Minutes preset advances in 15 minute intervals", () => {
|
||||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every15Minutes, {
|
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every15Minutes, {
|
||||||
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = cronDateToDate(interval.next());
|
const first = cronDateToDate(interval.next());
|
||||||
@@ -314,6 +318,7 @@ describe("Preset cron expression edge cases", () => {
|
|||||||
it("ensures every2Hours preset advances in 2 hour intervals", () => {
|
it("ensures every2Hours preset advances in 2 hour intervals", () => {
|
||||||
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every2Hours, {
|
const interval = CronExpressionParser.parse(AUTOMATION_PRESETS.every2Hours, {
|
||||||
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
currentDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = cronDateToDate(interval.next());
|
const first = cronDateToDate(interval.next());
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ describe("RoutineStore", () => {
|
|||||||
expect(new Date(next).getUTCHours()).toBe(13);
|
expect(new Date(next).getUTCHours()).toBe(13);
|
||||||
expect(new Date(next).getUTCMinutes()).toBe(0);
|
expect(new Date(next).getUTCMinutes()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("computes monthly runs against UTC instead of local machine time", () => {
|
||||||
|
const fromDate = new Date("2026-04-15T00:00:00Z");
|
||||||
|
const next = store.computeNextRun("0 0 1 * *", fromDate);
|
||||||
|
expect(next).toBe("2026-05-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── createRoutine ────────────────────────────────────────────────
|
// ── createRoutine ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
MAX_ROUTINE_RUN_HISTORY,
|
MAX_ROUTINE_RUN_HISTORY,
|
||||||
} from "./routine.js";
|
} from "./routine.js";
|
||||||
|
|
||||||
|
const CRON_TIMEZONE = "UTC";
|
||||||
|
|
||||||
export interface RoutineStoreEvents {
|
export interface RoutineStoreEvents {
|
||||||
"routine:created": [routine: Routine];
|
"routine:created": [routine: Routine];
|
||||||
"routine:updated": [routine: Routine];
|
"routine:updated": [routine: Routine];
|
||||||
@@ -242,6 +244,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|||||||
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: fromDate ?? new Date(),
|
currentDate: fromDate ?? new Date(),
|
||||||
|
tz: CRON_TIMEZONE,
|
||||||
});
|
});
|
||||||
const next = interval.next();
|
const next = interval.next();
|
||||||
return new Date(next.getTime()).toISOString();
|
return new Date(next.getTime()).toISOString();
|
||||||
|
|||||||
@@ -94,8 +94,16 @@ import {
|
|||||||
|
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
|
|
||||||
|
function expectEventsUrl(url: string, projectId?: string) {
|
||||||
|
const parsed = new URL(url, "http://localhost");
|
||||||
|
expect(parsed.pathname).toBe("/api/events");
|
||||||
|
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||||||
|
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
|
||||||
// Default implementations
|
// Default implementations
|
||||||
vi.mocked(fetchPlugins).mockResolvedValue([]);
|
vi.mocked(fetchPlugins).mockResolvedValue([]);
|
||||||
@@ -145,7 +153,10 @@ beforeEach(() => {
|
|||||||
onmessage: null,
|
onmessage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MockEventSource = vi.fn(() => eventSourceInstance) as unknown as typeof EventSource;
|
const MockEventSource = vi.fn((url: string) => {
|
||||||
|
eventSourceInstance.url = url;
|
||||||
|
return eventSourceInstance;
|
||||||
|
}) as unknown as typeof EventSource;
|
||||||
MockEventSource.CONNECTING = 0;
|
MockEventSource.CONNECTING = 0;
|
||||||
MockEventSource.OPEN = 1;
|
MockEventSource.OPEN = 1;
|
||||||
MockEventSource.CLOSED = 2;
|
MockEventSource.CLOSED = 2;
|
||||||
@@ -452,7 +463,9 @@ describe("PluginManager", () => {
|
|||||||
expect(fetchPlugins).toHaveBeenCalled();
|
expect(fetchPlugins).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(EventSource).toHaveBeenCalledWith("/api/events?projectId=proj-456");
|
const url = (globalThis as any).__testEventSourceInstance?.url;
|
||||||
|
expect(typeof url).toBe("string");
|
||||||
|
expectEventsUrl(url, "proj-456");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles plugin enabled SSE event", async () => {
|
it("handles plugin enabled SSE event", async () => {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ vi.mock("../../api", () => ({
|
|||||||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||||||
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
|
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
|
||||||
|
|
||||||
|
function expectEventsUrl(url: string, projectId?: string) {
|
||||||
|
const parsed = new URL(url, "http://localhost");
|
||||||
|
expect(parsed.pathname).toBe("/api/events");
|
||||||
|
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||||||
|
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||||
return {
|
return {
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
@@ -37,6 +44,7 @@ const defaultStats: AgentStats = {
|
|||||||
describe("useAgents", () => {
|
describe("useAgents", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
MockEventSource.instances = [];
|
MockEventSource.instances = [];
|
||||||
|
window.sessionStorage.clear();
|
||||||
mockFetchAgents.mockReset().mockResolvedValue([]);
|
mockFetchAgents.mockReset().mockResolvedValue([]);
|
||||||
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
|
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
@@ -141,7 +149,8 @@ describe("useAgents", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const urls = MockEventSource.instances.map((es) => es.url);
|
const urls = MockEventSource.instances.map((es) => es.url);
|
||||||
expect(urls).toContain("/api/events");
|
expect(urls.length).toBeGreaterThan(0);
|
||||||
|
expectEventsUrl(urls[urls.length - 1]!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +202,7 @@ describe("useAgents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const urls = MockEventSource.instances.map((es) => es.url);
|
const urls = MockEventSource.instances.map((es) => es.url);
|
||||||
expect(urls).toContain(`/api/events?projectId=${encodeURIComponent(projectId)}`);
|
expect(urls.length).toBeGreaterThan(0);
|
||||||
|
expectEventsUrl(urls[urls.length - 1]!, projectId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export function createSSE(
|
|||||||
|
|
||||||
return (_req: Request, res: Response) => {
|
return (_req: Request, res: Response) => {
|
||||||
const connectionId = nextConnectionId++;
|
const connectionId = nextConnectionId++;
|
||||||
const clientId = normalizeSSEClientId(_req.query.clientId);
|
const clientId = normalizeSSEClientId(_req.query?.clientId);
|
||||||
const socket = res.socket ?? _req.socket;
|
const socket = res.socket ?? _req.socket;
|
||||||
|
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
|||||||
Reference in New Issue
Block a user