Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ jobs:

strategy:
matrix:
node-version: [12.x, 14.x, 16.x, 18.x]
node-version: [18.x, 20.x, 22.x, 24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# This workflow will run tests using node and then publish a package to the npm registry when a release is created
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages

name: Publish npm Package
Expand All @@ -11,8 +11,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 18
- run: npm ci
Expand All @@ -23,8 +23,8 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 18
registry-url: https://registry.npmjs.org/
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Let's be honest, you'll only be able to get away with this once a year, on Chris
npm install clockblocker
```

Requires Node.js >= 18.

## Usage

```
Expand All @@ -54,7 +56,7 @@ const timeCompression = new ConstantTimeCompression(
{ hour: 7 }, // start at 7am reference ("real") time
{ hours: 6 }, // Relative Time: In the time the "fake" time shows the passage of 6 hours
{ hours: 3}, // Reference Time: Only 3 hours, real time will have elapsed
),
);

// So, by the time 10:00am (reference) rolls-around, the clock is back to normal 1-to-1 time.
// Fake clock will read: 10:00am, real clock: 10:00am
Expand Down
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
module.exports = {
roots: ['<rootDir>/test'],
testMatch: ['**/__tests__/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'],
// Modern Node makes `globalThis.performance` non-configurable, which the fake-timers
// implementation bundled with Jest 28 cannot hijack (it throws "Cannot assign to read
// only property 'performance'"). We never fake `performance`, so exclude it.
fakeTimers: {
doNotFake: ['performance'],
},
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "clockblocker",
"version": "0.0.1",
"version": "0.1.0",
"description": "A Typescript library for warping the very fabric of space-time, or for building deceitful clocks.",
"main": "dist/index.js",
"types": "dist/types/index.d.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "npm run clean && tsc",
"clean": "rm -rf ./dist",
"prepublishOnly": "npm run build && npm run test:ci",
"test:ci": "jest -c jest.config.ci.js",
"test": "jest --no-cache --runInBand",
"test:cov": "jest --coverage --no-cache --runInBand"
Expand All @@ -29,6 +30,9 @@
],
"author": "Jonathan Griggs <jonathan.griggs@gmail.com>",
"license": "MIT",
"engines": {
"node": ">=18"
},
"dependencies": {
"@js-temporal/polyfill": "^0.4.1"
},
Expand Down
16 changes: 7 additions & 9 deletions src/Clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class Clock {

get offset() {
const now = this.referenceTimeInMillis;
return (this._offset = this._timeDistortions.reduce((offset, distortion) => {
this._offset = this._timeDistortions.reduce((offset, distortion) => {
const window = distortion.getTimeWindow();
if (window.compareWithinWindow(now) === TimeWindowComparison.EARLIER) return offset;
if (window.compareWithinWindow(this._lastCheck) === TimeWindowComparison.LATER) return offset;
Expand All @@ -27,16 +27,14 @@ export default class Clock {
Math.min(now - window.windowStartInMillis, timeSinceLastCheck, window.windowEndInMillis - this._lastCheck),
);
return offset + distortion.getElapsedTimeInMillis(windowOffset, windowLength);
}, this._offset));
}

private markLastCheck() {
this._lastCheck = this.referenceTimeInMillis;
}, this._offset);
// Consume the elapsed slice so that reading `offset` (or `relativeTimeInMillis`) again
// without time advancing does not accumulate the same interval twice.
this._lastCheck = now;
return this._offset;
}

get relativeTimeInMillis() {
const relativeTime = this.referenceTimeInMillis + this.offset;
this.markLastCheck();
return relativeTime;
return this.referenceTimeInMillis + this.offset;
}
}
8 changes: 7 additions & 1 deletion src/ConstantTimeCompression.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import RelativeTimeDistortion from './RelativeTimeDistortion';
export default class TimeCompression extends RelativeTimeDistortion {
// Compression speeds the fake clock up: more relative time passes than reference time
// (relativeDuration > referenceDuration). `Clock` adds this return value to an offset
// that is itself added to the reference time, so what we return is the *offset delta*
// contributed by `numberOfMilliseconds` of reference time. Over a slice of real time
// `dt` the fake clock advances `dt * (relative / reference)`, so the offset (the gap
// between fake and real time) changes by `dt * (relative / reference - 1)`.
distortTime(numberOfMilliseconds: number): number {
return numberOfMilliseconds * (this.referenceDurationInMillis / this.relativeDurationInMillis);
return numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis - 1);
}
}
8 changes: 7 additions & 1 deletion src/ConstantTimeDilation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import RelativeTimeDistortion from './RelativeTimeDistortion';
export default class TimeDilation extends RelativeTimeDistortion {
// Dilation slows the fake clock down: less relative time passes than reference time
// (relativeDuration < referenceDuration). `Clock` adds this return value to an offset
// that is itself added to the reference time, so what we return is the *offset delta*
// contributed by `numberOfMilliseconds` of reference time. Over a slice of real time
// `dt` the fake clock advances `dt * (relative / reference)`, so the offset (the gap
// between fake and real time) changes by `dt * (relative / reference - 1)`.
distortTime(numberOfMilliseconds: number): number {
return -numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis);
return numberOfMilliseconds * (this.relativeDurationInMillis / this.referenceDurationInMillis - 1);
}
}
88 changes: 88 additions & 0 deletions test/Clock.invariants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Clock, ConstantTimeCompression, ConstantTimeDilation } from '../src/index';

// Properties that must hold for any combination of constant distortions, regardless of how
// the clock happens to be polled. These guard the offset-accumulation math in `Clock` as a
// whole, rather than any single hand-computed value.
describe(`Clock invariants`, () => {
const hour = 1000 * 60 * 60;

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(0));
});

afterEach(() => {
jest.useRealTimers();
});

// The README example: half-speed from 1am-7am, double-speed from 7am-10am, which nets out
// to zero distortion by 10am.
const buildDistortions = () => [
new ConstantTimeDilation({ hour: 1 }, { hours: 3 }, { hours: 6 }),
new ConstantTimeCompression({ hour: 7 }, { hours: 6 }, { hours: 3 }),
];

describe(`polling-frequency independence`, () => {
it(`reaches the same relative time whether polled once or every minute`, () => {
// Polled exactly once, after every window has elapsed.
const coarse = new Clock(buildDistortions());
jest.setSystemTime(12 * hour);
const coarseResult = coarse.relativeTimeInMillis;

// Polled every minute across the same span.
const fine = new Clock(buildDistortions());
let fineResult = 0;
for (let t = 0; t <= 12 * hour; t += 60 * 1000) {
jest.setSystemTime(t);
fineResult = fine.relativeTimeInMillis;
}

// Because every distortion is linear, the accumulated offset must not depend on how
// many slices the interval was broken into.
expect(fineResult).toEqual(coarseResult);
expect(fineResult).toEqual(12 * hour); // the distortions cancel out by noon
});
});

describe(`order independence`, () => {
it(`produces the same relative time regardless of the order of the distortions`, () => {
const [dilation, compression] = buildDistortions();
const forward = new Clock([dilation, compression]);
const reversed = new Clock([compression, dilation]);

// Walk both clocks through the same hourly checkpoints; their offsets are summed, so
// the array order must not matter at any point in time.
for (let h = 1; h <= 12; h++) {
jest.setSystemTime(h * hour);
expect(reversed.relativeTimeInMillis).toEqual(forward.relativeTimeInMillis);
}
});
});

describe(`round-trip net-zero`, () => {
it(`returns to perfect sync after a dilation is exactly compensated by a compression`, () => {
// Lose 3 hours over 1am-7am, then claw exactly 3 hours back over 7am-10am.
const clock = new Clock(buildDistortions());

// The clock runs behind for the whole excursion, peaking at -3h when the dilation ends
// at 7am, and the lag profile is symmetric: it reads -1.5h both halfway into the
// dilation (4am) and halfway back through the compression (8.5am).
jest.setSystemTime(4 * hour);
expect(clock.relativeTimeInMillis - 4 * hour).toEqual(-1.5 * hour);

jest.setSystemTime(7 * hour);
expect(clock.relativeTimeInMillis - 7 * hour).toEqual(-3 * hour);

jest.setSystemTime(8.5 * hour);
expect(clock.relativeTimeInMillis - 8.5 * hour).toEqual(-1.5 * hour);

// By 10am the windows are done and the offset must be back to exactly zero.
jest.setSystemTime(10 * hour);
expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis);

// ...and it stays in sync afterward, with no residual drift.
jest.setSystemTime(20 * hour);
expect(clock.relativeTimeInMillis).toEqual(clock.referenceTimeInMillis);
});
});
});
Loading