testing: drop the Date Proxy in FakeTime
- Dominant language
- TypeScript
- Stars
- 3.6k
- Forks
- 681
- PR merge metrics
- No merged PRs in 30d
Description
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](https://docs.deno.com/runtime/contributing/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:
```ts
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
```ts
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.
Contributor guide
Assessment
This issue has not been assessed yet.