diff --git a/.changeset/date-subclass-vm.md b/.changeset/date-subclass-vm.md new file mode 100644 index 0000000000..25760a72eb --- /dev/null +++ b/.changeset/date-subclass-vm.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Fix `Date` subclassing inside workflow functions. The deterministic `Date` override in the workflow VM now forwards `new.target` via `Reflect.construct`, so subclasses like `TZDate` from `@date-fns/tz` keep their identity, methods, and fields. Calling `Date()` without `new` now returns the (fixed) time string per spec, instead of a `Date` object. diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 3f2ebbabeb..5cc9bdef50 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -49,6 +49,64 @@ describe('createContext', () => { expect(result).toEqual(specificTime); }); + it('should support subclassing `Date`', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = vm.runInContext( + ` + class Sub extends Date { + constructor(...args) { + super(...args); + this.tag = 'sub'; + } + label() { + return 'sub'; + } + } + const sub = new Sub(2026, 6, 29); + const defaulted = new Sub(); + ({ + isSub: sub instanceof Sub, + isDate: sub instanceof Date, + keepsMethods: sub.label(), + keepsFields: sub.tag, + argsForwarded: sub.getTime() === new Date(2026, 6, 29).getTime(), + defaultedIsFixed: defaulted.getTime(), + }) + `, + context + ); + + expect(result.isSub).toBe(true); + expect(result.isDate).toBe(true); + expect(result.keepsMethods).toBe('sub'); + expect(result.keepsFields).toBe('sub'); + expect(result.argsForwarded).toBe(true); + expect(result.defaultedIsFixed).toEqual(fixedTimestamp); + }); + + it('should keep `Date()` callable without `new`, returning the fixed time string', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = vm.runInContext('Date()', context); + + expect(result).toBeTypeOf('string'); + expect(result).toEqual(vm.runInContext('new Date().toString()', context)); + // Per spec, `Date()` as a function ignores its arguments + expect(vm.runInContext('Date(2000, 0, 1)', context)).toEqual(result); + }); + + it('should preserve `Date` static methods', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect( + vm.runInContext("Date.parse('2000-01-01T00:00:00.000Z')", context) + ).toEqual(946684800000); + expect(vm.runInContext('Date.UTC(2000, 0, 1)', context)).toEqual( + 946684800000 + ); + }); + it('should have deterministic `crypto.getRandomValues()`', () => { const { context } = createContext({ seed, fixedTimestamp }); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index 0355abb884..a747075ac9 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -60,17 +60,22 @@ export function createContext(options: CreateContextOptions) { // Deterministic `Math.random()` g.Math.random = rng; - // Override `Date` constructor to return fixed time when called without arguments + // Override `Date` constructor to return fixed time when called without + // arguments. Constructing through `Reflect.construct` with `new.target` + // keeps subclassing intact (e.g. `TZDate` from `@date-fns/tz`), while a + // plain function (rather than a `class`) keeps `Date()` callable without + // `new`, which per spec ignores its arguments and returns the time string. const Date_ = g.Date; // biome-ignore lint/suspicious/noShadowRestrictedNames: We're shadowing the global `Date` property to make it deterministic. - (g as any).Date = function Date( - ...args: Parameters<(typeof globalThis)['Date']>[] - ) { - if (args.length === 0) { - return new Date_(fixedTimestamp); + (g as any).Date = function Date(...args: any[]) { + if (new.target === undefined) { + return new Date_(fixedTimestamp).toString(); } - // @ts-expect-error - Args is `Date` constructor arguments - return new Date_(...args); + return Reflect.construct( + Date_, + args.length === 0 ? [fixedTimestamp] : args, + new.target + ); }; (g as any).Date.prototype = Date_.prototype; // Preserve static methods