UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

507 lines (412 loc) 17.6 kB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { patchMath, createDeterministicDate, createConsole, } from "./environment.js"; describe("environment patching units", () => { describe("patchMath", () => { it("should preserve all Math methods except random", () => { const originalMath = Math; const patchedMath = patchMath(originalMath, "test-seed"); // Should preserve all other methods expect(patchedMath.abs).toBe(originalMath.abs); expect(patchedMath.sin).toBe(originalMath.sin); expect(patchedMath.cos).toBe(originalMath.cos); expect(patchedMath.PI).toBe(originalMath.PI); expect(patchedMath.E).toBe(originalMath.E); }); it("should replace Math.random with deterministic seeded function", () => { const originalMath = Math; const patchedMath = patchMath(originalMath, "test-workflow-id"); // Should return a number between 0 and 1 const random1 = patchedMath.random(); expect(random1).toBeGreaterThanOrEqual(0); expect(random1).toBeLessThan(1); // Should be deterministic - same seed produces same sequence const patchedMath2 = patchMath(originalMath, "test-workflow-id"); const random2 = patchedMath2.random(); expect(random2).toBe(random1); // Different seed produces different sequence const patchedMath3 = patchMath(originalMath, "different-workflow-id"); const random3 = patchedMath3.random(); expect(random3).not.toBe(random1); }); it("should not mutate the original Math object", () => { const originalMath = Math; const originalRandom = Math.random; patchMath(originalMath, "test-seed"); // Original Math should be unchanged expect(Math.random).toBe(originalRandom); }); }); describe("createDeterministicDate", () => { const mockGetGenerationState = vi.fn(); beforeEach(() => { mockGetGenerationState.mockReturnValue({ now: 1234567890000, latest: true, }); }); afterEach(() => { mockGetGenerationState.mockReset(); }); it("should create Date that uses generation state for Date.now()", () => { const testTime = 9876543210000; mockGetGenerationState.mockReturnValue({ now: testTime, latest: true }); const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); expect(DeterministicDate.now()).toBe(testTime); expect(mockGetGenerationState).toHaveBeenCalled(); }); it("should create new Date with current timestamp when no args", () => { const testTime = 1111111111111; mockGetGenerationState.mockReturnValue({ now: testTime, latest: true }); const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const date = new DeterministicDate(); expect(date.getTime()).toBe(testTime); }); it("should create Date with provided args", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const date = new DeterministicDate(2023, 0, 1); expect(date.getFullYear()).toBe(2023); expect(date.getMonth()).toBe(0); expect(date.getDate()).toBe(1); }); it("should return string when called without new", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const dateString = (DeterministicDate as unknown as () => string)(); expect(typeof dateString).toBe("string"); }); it("should preserve original Date static methods", () => { const originalDate = Date; const DeterministicDate = createDeterministicDate( originalDate, mockGetGenerationState, ); expect(DeterministicDate.parse).toBe(originalDate.parse); expect(DeterministicDate.UTC).toBe(originalDate.UTC); // Prototype should be the same as original (no patching currently) expect(DeterministicDate.prototype).toBe(originalDate.prototype); expect(DeterministicDate.prototype.constructor).toBe(DeterministicDate); }); it("should not affect the original Date constructor", () => { const originalNow = Date.now; createDeterministicDate(Date, mockGetGenerationState); // Original Date should be unchanged expect(Date.now).toBe(originalNow); }); describe("behavior validation vs original Date", () => { it("should produce identical outputs for specific dates", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); // Test with specific timestamps const timestamps = [ 0, // Unix epoch 946684800000, // Y2K 1640995200000, // 2022-01-01 2023, 0, 15, 10, 30, 45, 123, // Feb 15, 2023 10:30:45.123 ]; for (const ts of [timestamps[0], timestamps[1], timestamps[2]]) { const original = new Date(ts); const deterministic = new DeterministicDate(ts); expect(deterministic.getTime()).toBe(original.getTime()); expect(deterministic.getFullYear()).toBe(original.getFullYear()); expect(deterministic.getMonth()).toBe(original.getMonth()); expect(deterministic.getDate()).toBe(original.getDate()); expect(deterministic.getHours()).toBe(original.getHours()); expect(deterministic.getMinutes()).toBe(original.getMinutes()); expect(deterministic.getSeconds()).toBe(original.getSeconds()); expect(deterministic.getMilliseconds()).toBe( original.getMilliseconds(), ); } // Test with constructor args const original = new Date(2023, 0, 15, 10, 30, 45, 123); const deterministic = new DeterministicDate( 2023, 0, 15, 10, 30, 45, 123, ); expect(deterministic.getFullYear()).toBe(original.getFullYear()); expect(deterministic.getMonth()).toBe(original.getMonth()); expect(deterministic.getDate()).toBe(original.getDate()); expect(deterministic.getHours()).toBe(original.getHours()); expect(deterministic.getMinutes()).toBe(original.getMinutes()); expect(deterministic.getSeconds()).toBe(original.getSeconds()); expect(deterministic.getMilliseconds()).toBe( original.getMilliseconds(), ); }); it("should produce identical string representations for deterministic dates", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const timestamp = 1640995200000; // 2022-01-01T00:00:00.000Z const original = new Date(timestamp); const deterministic = new DeterministicDate(timestamp); expect(deterministic.toISOString()).toBe(original.toISOString()); expect(deterministic.toUTCString()).toBe(original.toUTCString()); expect(deterministic.toDateString()).toBe(original.toDateString()); expect(deterministic.toTimeString()).toBe(original.toTimeString()); expect(deterministic.toJSON()).toBe(original.toJSON()); expect(deterministic.valueOf()).toBe(original.valueOf()); expect(deterministic.getTime()).toBe(original.getTime()); }); it("should handle UTC methods identically", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const timestamp = 1640995200123; // 2022-01-01T00:00:00.123Z const original = new Date(timestamp); const deterministic = new DeterministicDate(timestamp); expect(deterministic.getUTCFullYear()).toBe(original.getUTCFullYear()); expect(deterministic.getUTCMonth()).toBe(original.getUTCMonth()); expect(deterministic.getUTCDate()).toBe(original.getUTCDate()); expect(deterministic.getUTCHours()).toBe(original.getUTCHours()); expect(deterministic.getUTCMinutes()).toBe(original.getUTCMinutes()); expect(deterministic.getUTCSeconds()).toBe(original.getUTCSeconds()); expect(deterministic.getUTCMilliseconds()).toBe( original.getUTCMilliseconds(), ); expect(deterministic.getUTCDay()).toBe(original.getUTCDay()); }); it("should handle static methods identically", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const dateString = "2023-01-15T10:30:45.123Z"; const year = 2023; const month = 0; // January const day = 15; const hour = 10; const minute = 30; const second = 45; const ms = 123; expect(DeterministicDate.parse(dateString)).toBe( Date.parse(dateString), ); expect( DeterministicDate.UTC(year, month, day, hour, minute, second, ms), ).toBe(Date.UTC(year, month, day, hour, minute, second, ms)); }); it("should maintain Date compatibility", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const date = new DeterministicDate(2023, 0, 1); // Should be an instance of Date (important for type compatibility) expect(date instanceof Date).toBe(true); // Should have all expected Date methods expect(typeof date.getTime).toBe("function"); expect(typeof date.getFullYear).toBe("function"); expect(typeof date.toISOString).toBe("function"); expect(typeof date.getTimezoneOffset).toBe("function"); expect(typeof date.toLocaleString).toBe("function"); }); it("should handle Date modification methods correctly", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const timestamp = 1640995200000; // 2022-01-01 const original = new Date(timestamp); const deterministic = new DeterministicDate(timestamp); // Test setters const newTime = 1641081600000; // 2022-01-02 original.setTime(newTime); deterministic.setTime(newTime); expect(deterministic.getTime()).toBe(original.getTime()); // Test setFullYear original.setFullYear(2024); deterministic.setFullYear(2024); expect(deterministic.getFullYear()).toBe(original.getFullYear()); expect(deterministic.getTime()).toBe(original.getTime()); }); it("should have timezone and locale methods available for future patching", () => { const DeterministicDate = createDeterministicDate( Date, mockGetGenerationState, ); const date = new DeterministicDate(1640995200000); // 2022-01-01T00:00:00.000Z // These methods exist but are not yet fully patched for determinism // They currently still use system timezone/locale settings expect(typeof date.getTimezoneOffset).toBe("function"); expect(typeof date.toLocaleString).toBe("function"); expect(typeof date.toLocaleDateString).toBe("function"); expect(typeof date.toLocaleTimeString).toBe("function"); // The methods work but results depend on system settings const timezoneOffset = date.getTimezoneOffset(); const localeString = date.toLocaleString(); const localeDateString = date.toLocaleDateString(); const localeTimeString = date.toLocaleTimeString(); expect(typeof timezoneOffset).toBe("number"); expect(typeof localeString).toBe("string"); expect(typeof localeDateString).toBe("string"); expect(typeof localeTimeString).toBe("string"); // Should be consistent when called multiple times expect(date.getTimezoneOffset()).toBe(timezoneOffset); expect(date.toLocaleString()).toBe(localeString); expect(date.toLocaleDateString()).toBe(localeDateString); expect(date.toLocaleTimeString()).toBe(localeTimeString); // TODO: These methods should be patched for full determinism: // - getTimezoneOffset() should return 0 (UTC) // - locale methods should use fixed locale (en-US) and UTC timezone }); }); }); describe("createConsole", () => { const mockGetGenerationState = vi.fn(); let mockConsole: { log: ReturnType<typeof vi.fn>; info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; group: ReturnType<typeof vi.fn>; groupEnd: ReturnType<typeof vi.fn>; }; beforeEach(() => { mockConsole = { log: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), group: vi.fn(), groupEnd: vi.fn(), }; }); afterEach(() => { mockGetGenerationState.mockReset(); }); it("should allow console methods when latest is true", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.log("test"); proxiedConsole.info("test"); proxiedConsole.warn("test"); proxiedConsole.error("test"); expect(mockConsole.log).toHaveBeenCalledWith("test"); expect(mockConsole.info).toHaveBeenCalledWith("test"); expect(mockConsole.warn).toHaveBeenCalledWith("test"); expect(mockConsole.error).toHaveBeenCalledWith("test"); }); it("should return noop function when latest is false", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); // Methods should be functions (noop) but not call the original expect(typeof proxiedConsole.log).toBe("function"); expect(typeof proxiedConsole.info).toBe("function"); proxiedConsole.log("test"); proxiedConsole.info("test"); expect(mockConsole.log).not.toHaveBeenCalled(); expect(mockConsole.info).not.toHaveBeenCalled(); }); it("should throw error for console.Console access", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); expect(() => proxiedConsole.Console).toThrow( "console.Console() is not supported within workflows", ); }); it("should handle console.count with state tracking", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.count("test"); proxiedConsole.count("test"); proxiedConsole.count(); // default label expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); expect(mockConsole.info).toHaveBeenCalledWith("test: 2"); expect(mockConsole.info).toHaveBeenCalledWith("default: 1"); }); it("should handle console.countReset", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: true }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.count("test"); proxiedConsole.count("test"); proxiedConsole.countReset("test"); proxiedConsole.count("test"); expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); expect(mockConsole.info).toHaveBeenCalledWith("test: 2"); expect(mockConsole.info).toHaveBeenCalledWith("test: 1"); }); it("should always pass through groupEnd", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.groupEnd(); expect(mockConsole.groupEnd).toHaveBeenCalled(); }); it("should handle time/timeEnd with generation state", () => { const startTime = 1000; const endTime = 1500; // Mock different return values for different calls mockGetGenerationState .mockReturnValueOnce({ now: startTime, latest: false }) // for time() .mockReturnValueOnce({ now: endTime, latest: true }); // for timeEnd() const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.time("test"); proxiedConsole.timeEnd("test"); expect(mockConsole.info).toHaveBeenCalledWith("test: 500ms"); }); it("should not call console methods for count when latest is false", () => { mockGetGenerationState.mockReturnValue({ now: 1000, latest: false }); const proxiedConsole = createConsole( mockConsole as unknown as Console, mockGetGenerationState, ); proxiedConsole.count("test"); proxiedConsole.count("test"); // Should not call info when latest is false expect(mockConsole.info).not.toHaveBeenCalled(); }); }); });