Skip to content

testing: drop the Date Proxy in FakeTime #7311

Description

@tomas-zijdemans

Proposal: replace the Date Proxy in testing/time.ts with a plain function. Follow-up to #7309, which fixed the subclass bug inside the Proxy and deliberately stopped there.

Why

The Deno Style Guide says: "Meta-programming is discouraged. Including the use of Proxy. Be explicit, even when it means more code." The Proxy exists to deliver three deviations from the real Date: now() reads the fake clock, zero-arg new Date() reads the fake clock, and Date() without new returns the fake time as a string. Three deviations do not need a Proxy to be explicit about.

The style guide is the smaller reason. The bigger one is a footgun the Proxy causes. It has no set or defineProperty trap, so writes fall through to the real constructor, while the get trap answers now before looking at the target. Result:

using time = new FakeTime(1000);
using nowSpy = spy(Date, "now");  // patches the REAL Date.now
Date.now();                        // still returns 1000 from the fake
nowSpy.calls.length;               // 0

The spy silently misses. Same for stub(Date, "parse", ...) inside a fake block: the stub lands on _internals.Date and is bypassed until restore(). A plain function with an own now property makes those writes land on the fake, where the test expects them.

Sketch

function FakeDate(this: unknown, ...args: unknown[]) {
  if (new.target === undefined) {
    return new _internals.Date(fakeTimeNow()).toString();
  }
  if (args.length === 0) args = [fakeTimeNow()];
  return Reflect.construct(_internals.Date, args, new.target);
}
// Statics resolve live through the real constructor. Writes stay on the fake.
Object.setPrototypeOf(FakeDate, _internals.Date);
Object.defineProperties(FakeDate, {
  prototype: { value: _internals.Date.prototype, writable: false },
  now: { value: fakeTimeNow, writable: true, configurable: true },
  length: { value: 7, configurable: true },
  name: { value: "Date", configurable: true },
});

Then globalThis.Date = FakeDate in overrideGlobals(). About 15 lines net.

Each piece earns its place. new.target === undefined is the call-without-new case. Reflect.construct with new.target keeps subclass prototypes, same as #7309. setPrototypeOf to the real Date means parse and UTC resolve up the chain on every read, so a stub installed on the real constructor at any time is visible through the fake, exactly as the Proxy behaves today. prototype must be the real Date.prototype or instanceof Date breaks for real instances while faked. length and name are own on every function and would otherwise report 0 and "FakeDate".

What changes

Probed on Deno 2.9.6 against the current Proxy:

Observation Proxy today Plain function
Date.now(), new Date(), Date(), instanceof, argument passthrough fake same
Date.parse stubbed on the real constructor, before or during a fake block stub visible same
spy(Date, "now") while faked patches real, 0 calls recorded patches fake, calls recorded
Date.parse = f while faked patches the real Date patches the fake only
Object.hasOwn(Date, "parse") true false
Object.getPrototypeOf(Date) Function.prototype real Date
Date.toString() on the constructor [native code] function source

Every read matches. The write rows are the intended change. The last three rows are the cost.

Tradeoffs, honestly

The reflection differences are real. hasOwn and getPrototypeOf on the constructor change, and Date.toString() shows source instead of [native code]. I found nothing in this repo that depends on any of them, and code that introspects the Date constructor object rather than using it is rare. But the Proxy has zero such regressions and this has three.

The write isolation is a behavior change for anyone who assigns to Date statics inside a FakeTime block. Today that write leaks to the real constructor and is ignored. After this it lands on the fake and works. I would call that a fix, not a break, but it belongs in the changelog either way.

I considered and rejected copying the real constructor's own descriptors onto the function instead of prototype delegation. It fixes hasOwn and getPrototypeOf parity, but turns statics into a snapshot: a Date.parse stub installed after import and before new FakeTime() goes invisible. Live reads matter more than two reflective properties.

Also rejected class FakeDate extends _internals.Date. Classes throw when called without new, and user subclasses built under fake time would chain through FakeDate.prototype, leaking the fake type into objects that outlive restore().

Tests

Seven cases pin the table: subclass keeps its prototype, zero-arg construction tracks tick(), new Date(undefined) stays invalid, a captured constructor works after restore(), static reads are live, static writes are isolated, and length/name/prototype identity. Four of those already landed in #7309.

Happy to open the PR if there is appetite. If the reflection rows are a dealbreaker, the Proxy as fixed in #7309 is fine and this can close.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions