Skip to content

Commit 4d69dc3

Browse files
authored
refactor: implement epoch clock logic (#1204)
1 parent 9d4a223 commit 4d69dc3

File tree

6 files changed

+342
-0
lines changed

6 files changed

+342
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import process from 'node:process';
2+
import { threadId } from 'node:worker_threads';
3+
import { describe, expect, it } from 'vitest';
4+
import { defaultClock, epochClock } from './clock-epoch.js';
5+
6+
describe('epochClock', () => {
7+
it('should create epoch clock with defaults', () => {
8+
const c = epochClock();
9+
expect(c).toStrictEqual(
10+
expect.objectContaining({
11+
tid: threadId,
12+
pid: process.pid,
13+
timeOriginMs: performance.timeOrigin,
14+
}),
15+
);
16+
expect(c.fromEpochMs).toBeFunction();
17+
expect(c.fromEpochUs).toBeFunction();
18+
expect(c.fromPerfMs).toBeFunction();
19+
expect(c.fromEntryStartTimeMs).toBeFunction();
20+
expect(c.fromDateNowMs).toBeFunction();
21+
});
22+
23+
it('should support performance clock by default for epochNowUs', () => {
24+
const c = epochClock();
25+
expect(c.timeOriginMs).toBe(performance.timeOrigin);
26+
const nowUs = c.epochNowUs();
27+
expect(nowUs).toBe(Math.round(nowUs));
28+
const expectedUs = Date.now() * 1000;
29+
30+
expect(nowUs).toBeWithin(expectedUs - 2000, expectedUs + 1000);
31+
});
32+
33+
it('should convert epoch milliseconds to microseconds correctly', () => {
34+
const c = epochClock();
35+
const epochMs = Date.now();
36+
37+
const result = c.fromEpochMs(epochMs);
38+
expect(result).toBeInteger();
39+
expect(result).toBe(Math.round(epochMs * 1000));
40+
});
41+
42+
it('should convert epoch microseconds to microseconds correctly', () => {
43+
const c = epochClock();
44+
const epochUs = Date.now() * 1000;
45+
const expectedUs = Math.round(epochUs);
46+
47+
const result = c.fromEpochUs(epochUs);
48+
expect(result).toBe(expectedUs);
49+
expect(result).toBe(Math.round(result));
50+
});
51+
52+
it('should convert performance milliseconds to epoch microseconds correctly', () => {
53+
const c = epochClock();
54+
const perfMs = performance.now();
55+
const expectedUs = Math.round((c.timeOriginMs + perfMs) * 1000);
56+
57+
const result = c.fromPerfMs(perfMs);
58+
expect(result).toBe(expectedUs);
59+
expect(result).toBeInteger();
60+
});
61+
62+
it('should convert entry start time milliseconds to epoch microseconds correctly', () => {
63+
const c = epochClock();
64+
const entryStartMs = performance.mark('fromPerfMs').startTime;
65+
const expectedUs = Math.round((c.timeOriginMs + entryStartMs) * 1000);
66+
67+
const result = c.fromEntryStartTimeMs(entryStartMs);
68+
expect(result).toBe(expectedUs);
69+
expect(result).toBe(Math.round(result));
70+
});
71+
72+
it('should convert Date.now milliseconds to epoch microseconds correctly', () => {
73+
const c = epochClock();
74+
const dateNowMs = Date.now();
75+
76+
const result = c.fromDateNowMs(dateNowMs);
77+
expect(result).toBe(Math.round(dateNowMs * 1000));
78+
expect(result).toBe(Math.round(result));
79+
});
80+
});
81+
82+
describe('defaultClock', () => {
83+
it('should have valid defaultClock export', () => {
84+
const c = defaultClock;
85+
expect(c).toStrictEqual(
86+
expect.objectContaining({
87+
tid: threadId,
88+
pid: process.pid,
89+
timeOriginMs: performance.timeOrigin,
90+
}),
91+
);
92+
93+
expect(c.fromEpochMs).toBeFunction();
94+
expect(c.fromEpochUs).toBeFunction();
95+
expect(c.fromPerfMs).toBeFunction();
96+
expect(c.fromEntryStartTimeMs).toBeFunction();
97+
expect(c.fromDateNowMs).toBeFunction();
98+
});
99+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import process from 'node:process';
2+
import { threadId } from 'node:worker_threads';
3+
4+
export type Microseconds = number;
5+
export type Milliseconds = number;
6+
7+
const msToUs = (ms: number): Microseconds => Math.round(ms * 1000);
8+
const usToUs = (us: number): Microseconds => Math.round(us);
9+
10+
/**
11+
* Defines clock utilities for time conversions.
12+
* Handles time origins in NodeJS and the Browser
13+
* Provides process and thread IDs.
14+
* @param init
15+
*/
16+
export type EpochClockOptions = {
17+
pid?: number;
18+
tid?: number;
19+
};
20+
21+
/**
22+
* Creates epoch-based clock utility.
23+
* Epoch time has been the time since January 1, 1970 (UNIX epoch).
24+
* Date.now gives epoch time in milliseconds.
25+
* performance.now() + performance.timeOrigin when available is used for higher precision.
26+
*/
27+
export function epochClock(init: EpochClockOptions = {}) {
28+
const pid = init.pid ?? process.pid;
29+
const tid = init.tid ?? threadId;
30+
31+
const timeOriginMs = performance.timeOrigin;
32+
33+
const epochNowUs = (): Microseconds =>
34+
msToUs(timeOriginMs + performance.now());
35+
36+
const fromEpochUs = usToUs;
37+
38+
const fromEpochMs = msToUs;
39+
40+
const fromPerfMs = (perfMs: Milliseconds): Microseconds =>
41+
msToUs(timeOriginMs + perfMs);
42+
43+
const fromEntryStartTimeMs = fromPerfMs;
44+
const fromDateNowMs = fromEpochMs;
45+
46+
return {
47+
timeOriginMs,
48+
pid,
49+
tid,
50+
51+
epochNowUs,
52+
msToUs,
53+
usToUs,
54+
55+
fromEpochMs,
56+
fromEpochUs,
57+
fromPerfMs,
58+
fromEntryStartTimeMs,
59+
fromDateNowMs,
60+
};
61+
}
62+
63+
export const defaultClock = epochClock();
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { defaultClock, epochClock } from './clock-epoch.js';
3+
4+
describe('epochClock', () => {
5+
it('should create epoch clock with defaults', () => {
6+
const c = epochClock();
7+
expect(c.timeOriginMs).toBe(500_000);
8+
expect(c.tid).toBe(2);
9+
expect(c.pid).toBe(10_001);
10+
expect(c.fromEpochMs).toBeFunction();
11+
expect(c.fromEpochUs).toBeFunction();
12+
expect(c.fromPerfMs).toBeFunction();
13+
expect(c.fromEntryStartTimeMs).toBeFunction();
14+
expect(c.fromDateNowMs).toBeFunction();
15+
});
16+
17+
it('should use pid options', () => {
18+
expect(epochClock({ pid: 999 })).toStrictEqual(
19+
expect.objectContaining({
20+
pid: 999,
21+
}),
22+
);
23+
});
24+
25+
it('should use tid options', () => {
26+
expect(epochClock({ tid: 888 })).toStrictEqual(
27+
expect.objectContaining({
28+
tid: 888,
29+
}),
30+
);
31+
});
32+
33+
it('should support performance clock by default for epochNowUs', () => {
34+
const c = epochClock();
35+
expect(c.timeOriginMs).toBe(500_000);
36+
expect(c.epochNowUs()).toBe(1_000_000_000); // timeOrigin + (Date.now() - timeOrigin) = Date.now()
37+
});
38+
39+
it.each([
40+
[1_000_000_000, 1_000_000_000],
41+
[1_001_000_000, 1_001_000_000],
42+
[999_000_000, 999_000_000],
43+
])('should convert epoch microseconds to microseconds', (us, result) => {
44+
const c = epochClock();
45+
expect(c.fromEpochUs(us)).toBe(result);
46+
});
47+
48+
it.each([
49+
[1_000_000, 1_000_000_000],
50+
[1_001_000.5, 1_001_000_500],
51+
[999_000.4, 999_000_400],
52+
])('should convert epoch milliseconds to microseconds', (ms, result) => {
53+
const c = epochClock();
54+
expect(c.fromEpochMs(ms)).toBe(result);
55+
});
56+
57+
it.each([
58+
[0, 500_000_000],
59+
[1000, 501_000_000],
60+
])(
61+
'should convert performance milliseconds to microseconds',
62+
(perfMs, expected) => {
63+
expect(epochClock().fromPerfMs(perfMs)).toBe(expected);
64+
},
65+
);
66+
67+
it('should convert entry start time to microseconds', () => {
68+
const c = epochClock();
69+
expect([
70+
c.fromEntryStartTimeMs(0),
71+
c.fromEntryStartTimeMs(1000),
72+
]).toStrictEqual([c.fromPerfMs(0), c.fromPerfMs(1000)]);
73+
});
74+
75+
it('should convert Date.now() milliseconds to microseconds', () => {
76+
const c = epochClock();
77+
expect([
78+
c.fromDateNowMs(1_000_000),
79+
c.fromDateNowMs(2_000_000),
80+
]).toStrictEqual([1_000_000_000, 2_000_000_000]);
81+
});
82+
83+
it('should maintain conversion consistency', () => {
84+
const c = epochClock();
85+
86+
expect({
87+
fromEpochUs_2B: c.fromEpochUs(2_000_000_000),
88+
fromEpochMs_2M: c.fromEpochMs(2_000_000),
89+
fromEpochUs_1B: c.fromEpochUs(1_000_000_000),
90+
fromEpochMs_1M: c.fromEpochMs(1_000_000),
91+
}).toStrictEqual({
92+
fromEpochUs_2B: 2_000_000_000,
93+
fromEpochMs_2M: 2_000_000_000,
94+
fromEpochUs_1B: 1_000_000_000,
95+
fromEpochMs_1M: 1_000_000_000,
96+
});
97+
});
98+
99+
it.each([
100+
[1_000_000_000.1, 1_000_000_000],
101+
[1_000_000_000.4, 1_000_000_000],
102+
[1_000_000_000.5, 1_000_000_001],
103+
[1_000_000_000.9, 1_000_000_001],
104+
])('should round microseconds correctly', (value, result) => {
105+
const c = epochClock();
106+
expect(c.fromEpochUs(value)).toBe(result);
107+
});
108+
});
109+
110+
describe('defaultClock', () => {
111+
it('should have valid defaultClock export', () => {
112+
expect({
113+
tid: typeof defaultClock.tid,
114+
timeOriginMs: typeof defaultClock.timeOriginMs,
115+
}).toStrictEqual({
116+
tid: 'number',
117+
timeOriginMs: 'number',
118+
});
119+
});
120+
});

testing/test-setup-config/src/lib/vitest-setup-files.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const UNIT_TEST_SETUP_FILES = [
2525
'../../testing/test-setup/src/lib/logger.mock.ts',
2626
'../../testing/test-setup/src/lib/git.mock.ts',
2727
'../../testing/test-setup/src/lib/portal-client.mock.ts',
28+
'../../testing/test-setup/src/lib/process.setup-file.ts',
29+
'../../testing/test-setup/src/lib/clock.setup-file.ts',
2830
...CUSTOM_MATCHERS,
2931
] as const;
3032

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type MockInstance, afterEach, beforeEach, vi } from 'vitest';
2+
3+
const MOCK_DATE_NOW_MS = 1_000_000;
4+
const MOCK_TIME_ORIGIN = 500_000;
5+
6+
const dateNow = MOCK_DATE_NOW_MS;
7+
const performanceTimeOrigin = MOCK_TIME_ORIGIN;
8+
9+
/* eslint-disable functional/no-let */
10+
let dateNowSpy: MockInstance<[], number> | undefined;
11+
let performanceNowSpy: MockInstance<[], number> | undefined;
12+
/* eslint-enable functional/no-let */
13+
14+
beforeEach(() => {
15+
dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(dateNow);
16+
performanceNowSpy = vi
17+
.spyOn(performance, 'now')
18+
.mockReturnValue(dateNow - performanceTimeOrigin);
19+
20+
vi.stubGlobal('performance', {
21+
...performance,
22+
timeOrigin: performanceTimeOrigin,
23+
});
24+
});
25+
26+
afterEach(() => {
27+
vi.unstubAllGlobals();
28+
29+
if (dateNowSpy) {
30+
dateNowSpy.mockRestore();
31+
dateNowSpy = undefined;
32+
}
33+
if (performanceNowSpy) {
34+
performanceNowSpy.mockRestore();
35+
performanceNowSpy = undefined;
36+
}
37+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import process from 'node:process';
2+
import { afterEach, beforeEach, vi } from 'vitest';
3+
4+
export const MOCK_PID = 10_001;
5+
export const MOCK_TID = 2;
6+
7+
vi.mock('node:worker_threads', () => ({
8+
get threadId() {
9+
return MOCK_TID;
10+
},
11+
}));
12+
13+
const processMock = vi.spyOn(process, 'pid', 'get');
14+
15+
beforeEach(() => {
16+
processMock.mockReturnValue(MOCK_PID);
17+
});
18+
19+
afterEach(() => {
20+
processMock.mockClear();
21+
});

0 commit comments

Comments
 (0)