@c9up/helix 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/suite.d.ts +26 -0
- package/dist/runtime/suite.d.ts.map +1 -1
- package/dist/runtime/suite.js +21 -18
- package/dist/runtime/suite.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -2
- package/src/cli/coverage/aggregate.ts +0 -231
- package/src/cli/coverage/collect.ts +0 -63
- package/src/cli/coverage/diff/base.ts +0 -46
- package/src/cli/coverage/diff/index.ts +0 -160
- package/src/cli/coverage/diff/overlay.ts +0 -62
- package/src/cli/coverage/diff/parse.ts +0 -121
- package/src/cli/coverage/diff/reporters.ts +0 -82
- package/src/cli/coverage/diff/types.ts +0 -46
- package/src/cli/coverage/filter.ts +0 -71
- package/src/cli/coverage/glob.ts +0 -0
- package/src/cli/coverage/index.ts +0 -126
- package/src/cli/coverage/reporters/json.ts +0 -40
- package/src/cli/coverage/reporters/lcov.ts +0 -54
- package/src/cli/coverage/reporters/text.ts +0 -48
- package/src/cli/coverage/thresholds.ts +0 -73
- package/src/cli/coverage/types.ts +0 -93
- package/src/cli/discover.ts +0 -174
- package/src/cli/native.ts +0 -104
- package/src/cli/pool.ts +0 -486
- package/src/cli/reporter.ts +0 -155
- package/src/cli/run.ts +0 -440
- package/src/cli/summary.ts +0 -42
- package/src/cli/watch/loop.ts +0 -159
- package/src/cli/watch/types.ts +0 -22
- package/src/cli/watch/watcher.ts +0 -145
- package/src/container/index.ts +0 -16
- package/src/container/override.ts +0 -86
- package/src/container/spy.ts +0 -25
- package/src/index.ts +0 -42
- package/src/runtime/assertion-error.ts +0 -38
- package/src/runtime/cli-worker.ts +0 -140
- package/src/runtime/equals.ts +0 -400
- package/src/runtime/expect.ts +0 -173
- package/src/runtime/index.ts +0 -50
- package/src/runtime/lifecycle.ts +0 -17
- package/src/runtime/matchers.ts +0 -452
- package/src/runtime/run.ts +0 -573
- package/src/runtime/suite.ts +0 -310
- package/src/runtime/test-context.ts +0 -59
- package/src/runtime/vi/fake-timers.ts +0 -410
- package/src/runtime/vi/index.ts +0 -254
- package/src/runtime/vi/spy.ts +0 -224
- package/src/runtime/vi/spyOn.ts +0 -155
- package/src/runtime/vi/system-time.ts +0 -121
- package/src/runtime/worker.ts +0 -239
- package/src/time/freeze.ts +0 -229
- package/src/time/index.ts +0 -16
package/src/runtime/worker.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worker entry — loads a single test file, collects its `describe`/`test`
|
|
3
|
-
* declarations, executes them, and emits a `FileResult`.
|
|
4
|
-
*
|
|
5
|
-
* Usage modes:
|
|
6
|
-
* 1. Direct (unit tests): `runTestFile("/abs/path.test.ts")` → `FileResult`
|
|
7
|
-
* 2. Child process (orchestrator): `main()` reads file paths from IPC
|
|
8
|
-
* messages and replies on `process.send`. Exactly one `main()` runs
|
|
9
|
-
* per worker process (guarded); IPC runs are serialized so concurrent
|
|
10
|
-
* messages cannot race on internal state.
|
|
11
|
-
*
|
|
12
|
-
* File collection is scoped per invocation via AsyncLocalStorage (see
|
|
13
|
-
* `suite.ts#withCollection`) — no shared mutable state across concurrent
|
|
14
|
-
* `runTestFile` calls.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { pathToFileURL } from "node:url";
|
|
19
|
-
import { type ExecuteOptions, executeRoot, type FileResult } from "./run.js";
|
|
20
|
-
import { withCollection } from "./suite.js";
|
|
21
|
-
import { withViContext } from "./vi/index.js";
|
|
22
|
-
|
|
23
|
-
export interface RunFileOptions extends ExecuteOptions {
|
|
24
|
-
/**
|
|
25
|
-
* Bust the ESM module cache so repeated runs of the same path re-execute
|
|
26
|
-
* the file body and re-collect tests. Default: true.
|
|
27
|
-
*/
|
|
28
|
-
freshImport?: boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function assertAbsolute(p: string): void {
|
|
32
|
-
if (!path.isAbsolute(p)) {
|
|
33
|
-
throw new Error(
|
|
34
|
-
`runTestFile: expected absolute path, got "${p}". Resolve against cwd or __dirname before calling.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Turn values that `JSON.stringify` rejects into readable fallbacks. */
|
|
40
|
-
function safeValue(v: unknown, seen = new WeakSet<object>()): unknown {
|
|
41
|
-
if (v === null || v === undefined) return v;
|
|
42
|
-
if (typeof v === "bigint") return `${v}n`;
|
|
43
|
-
if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`;
|
|
44
|
-
if (typeof v === "symbol") return v.toString();
|
|
45
|
-
if (typeof v !== "object") return v;
|
|
46
|
-
if (seen.has(v)) return "[Circular]";
|
|
47
|
-
seen.add(v);
|
|
48
|
-
if (Array.isArray(v)) return v.map((item) => safeValue(item, seen));
|
|
49
|
-
if (v instanceof Map) {
|
|
50
|
-
const out: Array<[unknown, unknown]> = [];
|
|
51
|
-
for (const [k, val] of v)
|
|
52
|
-
out.push([safeValue(k, seen), safeValue(val, seen)]);
|
|
53
|
-
return { __type: "Map", entries: out };
|
|
54
|
-
}
|
|
55
|
-
if (v instanceof Set) {
|
|
56
|
-
return { __type: "Set", values: [...v].map((x) => safeValue(x, seen)) };
|
|
57
|
-
}
|
|
58
|
-
if (v instanceof Date) return { __type: "Date", iso: v.toISOString() };
|
|
59
|
-
if (v instanceof RegExp)
|
|
60
|
-
return { __type: "RegExp", src: v.source, flags: v.flags };
|
|
61
|
-
if (v instanceof Error) {
|
|
62
|
-
return {
|
|
63
|
-
__type: "Error",
|
|
64
|
-
name: v.name,
|
|
65
|
-
message: v.message,
|
|
66
|
-
stack: v.stack,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
const rec: Record<string, unknown> = {};
|
|
70
|
-
for (const key of Object.keys(v)) {
|
|
71
|
-
rec[key] = safeValue(Reflect.get(v, key), seen);
|
|
72
|
-
}
|
|
73
|
-
return rec;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function sanitizeTest(
|
|
77
|
-
t: FileResult["tests"][number],
|
|
78
|
-
): FileResult["tests"][number] {
|
|
79
|
-
if (!t.error) return t;
|
|
80
|
-
return {
|
|
81
|
-
...t,
|
|
82
|
-
error: {
|
|
83
|
-
...t.error,
|
|
84
|
-
actual: safeValue(t.error.actual),
|
|
85
|
-
expected: safeValue(t.error.expected),
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function sanitizeSuite(
|
|
91
|
-
s: FileResult["suites"][number],
|
|
92
|
-
): FileResult["suites"][number] {
|
|
93
|
-
return {
|
|
94
|
-
...s,
|
|
95
|
-
hookErrors: s.hookErrors.map((e) => ({
|
|
96
|
-
...e,
|
|
97
|
-
actual: safeValue(e.actual),
|
|
98
|
-
expected: safeValue(e.expected),
|
|
99
|
-
})),
|
|
100
|
-
children: s.children.map((c) =>
|
|
101
|
-
"children" in c ? sanitizeSuite(c) : sanitizeTest(c),
|
|
102
|
-
),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function sanitize(result: FileResult): FileResult {
|
|
107
|
-
return {
|
|
108
|
-
...result,
|
|
109
|
-
tests: result.tests.map(sanitizeTest),
|
|
110
|
-
suites: result.suites.map(sanitizeSuite),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
let importCounter = 0;
|
|
115
|
-
|
|
116
|
-
export async function runTestFile(
|
|
117
|
-
absolutePath: string,
|
|
118
|
-
options: RunFileOptions = {},
|
|
119
|
-
): Promise<FileResult> {
|
|
120
|
-
assertAbsolute(absolutePath);
|
|
121
|
-
const baseUrl = pathToFileURL(absolutePath).href;
|
|
122
|
-
// Cache-busting query param so the ESM loader re-evaluates the module on
|
|
123
|
-
// every call — otherwise `describe`/`test` would only register on the
|
|
124
|
-
// first call and subsequent runs would see an empty suite tree.
|
|
125
|
-
const url =
|
|
126
|
-
options.freshImport === false
|
|
127
|
-
? baseUrl
|
|
128
|
-
: `${baseUrl}?helix=${Date.now()}-${++importCounter}`;
|
|
129
|
-
return withViContext(async () => {
|
|
130
|
-
const root = await withCollection(async () => {
|
|
131
|
-
await import(url);
|
|
132
|
-
});
|
|
133
|
-
const raw = await executeRoot(root, absolutePath, {
|
|
134
|
-
timeoutMs: options.timeoutMs,
|
|
135
|
-
});
|
|
136
|
-
return sanitize(raw);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
interface WorkerIncoming {
|
|
141
|
-
type: "run";
|
|
142
|
-
file: string;
|
|
143
|
-
timeoutMs?: number;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
interface WorkerOutgoing {
|
|
147
|
-
type: "result";
|
|
148
|
-
result: FileResult;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
interface WorkerError {
|
|
152
|
-
type: "error";
|
|
153
|
-
file: string | undefined;
|
|
154
|
-
message: string;
|
|
155
|
-
stack?: string;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
type WorkerMessage = WorkerOutgoing | WorkerError;
|
|
159
|
-
|
|
160
|
-
const FRAME_PREFIX = "__HELIX_RESULT__";
|
|
161
|
-
|
|
162
|
-
function send(msg: WorkerMessage): void {
|
|
163
|
-
if (typeof process.send === "function") {
|
|
164
|
-
process.send(msg);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
// Fallback: framed line on stderr so it doesn't collide with test
|
|
168
|
-
// console.log output on stdout. Parent parses lines starting with the
|
|
169
|
-
// `FRAME_PREFIX` magic.
|
|
170
|
-
process.stderr.write(`${FRAME_PREFIX}${JSON.stringify(msg)}\n`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function isWorkerIncoming(v: unknown): v is WorkerIncoming {
|
|
174
|
-
if (!v || typeof v !== "object") return false;
|
|
175
|
-
const r = v as { type?: unknown; file?: unknown };
|
|
176
|
-
return r.type === "run" && typeof r.file === "string";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
let mainStarted = false;
|
|
180
|
-
|
|
181
|
-
export async function main(): Promise<void> {
|
|
182
|
-
if (mainStarted) return;
|
|
183
|
-
mainStarted = true;
|
|
184
|
-
|
|
185
|
-
// Unhandled rejections from test code (e.g. a dangling Promise.reject)
|
|
186
|
-
// would crash the worker on Node 15+ or leak silently. Log them and keep
|
|
187
|
-
// the process alive so currently-running tests can complete.
|
|
188
|
-
process.on("unhandledRejection", (reason) => {
|
|
189
|
-
process.stderr.write(
|
|
190
|
-
`${FRAME_PREFIX}${JSON.stringify({
|
|
191
|
-
type: "error",
|
|
192
|
-
file: undefined,
|
|
193
|
-
message: `unhandledRejection: ${reason instanceof Error ? reason.message : String(reason)}`,
|
|
194
|
-
stack: reason instanceof Error ? reason.stack : undefined,
|
|
195
|
-
})}\n`,
|
|
196
|
-
);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// IPC mode: parent drives us via `{ type: "run", file }` messages.
|
|
200
|
-
if (typeof process.send === "function") {
|
|
201
|
-
let pending: Promise<unknown> = Promise.resolve();
|
|
202
|
-
process.on("message", (raw: unknown) => {
|
|
203
|
-
if (!isWorkerIncoming(raw)) return;
|
|
204
|
-
const msg = raw;
|
|
205
|
-
// Serialize IPC runs: a second message waits for the first to
|
|
206
|
-
// finish so module-scoped watchers / handlers don't race.
|
|
207
|
-
pending = pending
|
|
208
|
-
.then(() => runTestFile(msg.file, { timeoutMs: msg.timeoutMs }))
|
|
209
|
-
.then((result) => send({ type: "result", result }))
|
|
210
|
-
.catch((err: unknown) => {
|
|
211
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
212
|
-
send({
|
|
213
|
-
type: "error",
|
|
214
|
-
file: msg.file,
|
|
215
|
-
message: e.message,
|
|
216
|
-
stack: e.stack,
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// CLI fallback: `node worker.js <file>`.
|
|
224
|
-
const file = process.argv[2];
|
|
225
|
-
if (!file) {
|
|
226
|
-
process.stderr.write("helix worker: missing file arg\n");
|
|
227
|
-
process.exit(2);
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
const abs = path.resolve(file);
|
|
231
|
-
const result = await runTestFile(abs);
|
|
232
|
-
send({ type: "result", result });
|
|
233
|
-
process.exit(result.totals.fail > 0 ? 1 : 0);
|
|
234
|
-
} catch (err) {
|
|
235
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
236
|
-
send({ type: "error", file, message: e.message, stack: e.stack });
|
|
237
|
-
process.exit(2);
|
|
238
|
-
}
|
|
239
|
-
}
|
package/src/time/freeze.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Time freeze / travel — ergonomic surface over `vi.setSystemTime`
|
|
3
|
-
* with per-test auto-restore.
|
|
4
|
-
*
|
|
5
|
-
* **Helix is agnostic — no cross-package imports.** This module
|
|
6
|
-
* never imports `@c9up/chronos`. The clock pin happens entirely
|
|
7
|
-
* through `vi.setSystemTime` (which shims `globalThis.Date`); any
|
|
8
|
-
* code in user-land that calls `new Date()` — chronos `DateTime`
|
|
9
|
-
* included — picks up the frozen epoch automatically.
|
|
10
|
-
*
|
|
11
|
-
* Calendar arithmetic for `month` / `year` uses `Date.UTC` math
|
|
12
|
-
* locally so we don't take a runtime dependency on chronos.
|
|
13
|
-
*
|
|
14
|
-
* **Concurrency note.** `vi.setSystemTime` writes to the `vi`
|
|
15
|
-
* context that wraps the current test FILE (`withViContext`), not
|
|
16
|
-
* the per-test frame (`withTestContext`). Two `withTestContext`
|
|
17
|
-
* frames running in parallel inside the same file (e.g. via
|
|
18
|
-
* `Promise.all([withTestContext(A), withTestContext(B)])`) share
|
|
19
|
-
* one Date shim — A's freeze leaks into B. The helix runtime
|
|
20
|
-
* executes tests strictly sequentially (`runSuite`'s for-await
|
|
21
|
-
* loop), so this is not reachable through the documented test
|
|
22
|
-
* path. Callers who manually compose `withTestContext` in parallel
|
|
23
|
-
* inside a single file must serialise their freezes themselves.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { inTestContext, registerTestCleanup } from "../runtime/test-context.js";
|
|
27
|
-
import { vi } from "../runtime/vi/index.js";
|
|
28
|
-
|
|
29
|
-
/** Anything with a `toMillis()` method (e.g. chronos `DateTime`,
|
|
30
|
-
* Luxon `DateTime`) is accepted via duck typing — no import. */
|
|
31
|
-
interface MillisLike {
|
|
32
|
-
toMillis(): number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Inputs accepted by `freeze` / `travelTo`. */
|
|
36
|
-
export type TimeInput = string | number | Date | MillisLike;
|
|
37
|
-
|
|
38
|
-
/** Calendar units supported by `time.travel`. `ms` is the bare-metal
|
|
39
|
-
* fallback; `month` and `year` use `Date.UTC` math; the rest are pure
|
|
40
|
-
* ms multipliers. UTC-only — tests should freeze to a UTC instant. */
|
|
41
|
-
export type TimeUnit =
|
|
42
|
-
| "ms"
|
|
43
|
-
| "second"
|
|
44
|
-
| "minute"
|
|
45
|
-
| "hour"
|
|
46
|
-
| "day"
|
|
47
|
-
| "week"
|
|
48
|
-
| "month"
|
|
49
|
-
| "year";
|
|
50
|
-
|
|
51
|
-
/** JS `Date` represents instants from `-8.64e15` to `+8.64e15` ms.
|
|
52
|
-
* Anything beyond becomes "Invalid Date" — refuse early so callers
|
|
53
|
-
* see a clear error instead of `setSystemTime(NaN)` downstream. */
|
|
54
|
-
const JS_DATE_MAX = 8_640_000_000_000_000;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Pin `Date.now()` / `new Date()` (and anything that reads through
|
|
58
|
-
* them — e.g. `DateTime.now()`) to the given moment. Auto-restored
|
|
59
|
-
* at end-of-test via the helix test frame, so a forgotten
|
|
60
|
-
* `unfreeze()` does not leak into the next test.
|
|
61
|
-
*
|
|
62
|
-
* Throws when called outside a test frame — a queued cleanup would
|
|
63
|
-
* never fire there, so the freeze would leak across tests silently.
|
|
64
|
-
*/
|
|
65
|
-
export function freeze(input: TimeInput): void {
|
|
66
|
-
requireTestContext("freeze");
|
|
67
|
-
const ms = inputToMs(input);
|
|
68
|
-
vi.setSystemTime(ms);
|
|
69
|
-
registerTestCleanup(() => {
|
|
70
|
-
vi.useRealSystemTime();
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Advance the clock by `amount` × `unit`. If the clock isn't frozen
|
|
76
|
-
* yet, freezes it relative to the real `Date.now()` first, then
|
|
77
|
-
* applies the delta — so a one-shot `time.travel(7, 'day')` works
|
|
78
|
-
* without an explicit `freeze()`.
|
|
79
|
-
*
|
|
80
|
-
* Always queues a cleanup so the auto-restore path is uniform —
|
|
81
|
-
* cleanups are idempotent (each calls `vi.useRealSystemTime`),
|
|
82
|
-
* accumulating closures across many `travel` calls in one test is
|
|
83
|
-
* intentional and cheap.
|
|
84
|
-
*
|
|
85
|
-
* Throws when called outside a test frame, same reason as `freeze`.
|
|
86
|
-
*/
|
|
87
|
-
export function travel(amount: number, unit: TimeUnit = "ms"): void {
|
|
88
|
-
requireTestContext("travel");
|
|
89
|
-
if (!Number.isFinite(amount)) {
|
|
90
|
-
throw new Error(`helix.time.travel: amount must be finite, got ${amount}.`);
|
|
91
|
-
}
|
|
92
|
-
if ((unit === "month" || unit === "year") && !Number.isInteger(amount)) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
`helix.time.travel: '${unit}' requires an integer amount, got ${amount}. Calendar units don't compose cleanly with fractions.`,
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
const base = vi.getMockedSystemTime() ?? Date.now();
|
|
98
|
-
const next = applyDelta(base, amount, unit);
|
|
99
|
-
requireInDateRange(next, "travel");
|
|
100
|
-
vi.setSystemTime(next);
|
|
101
|
-
// Always queue — `vi.useRealSystemTime` is idempotent so duplicate
|
|
102
|
-
// cleanups across `freeze + travel` are harmless. Always-queue
|
|
103
|
-
// guards against the "user pinned via raw `vi.setSystemTime`,
|
|
104
|
-
// then called `travel`" trap where the heuristic would skip.
|
|
105
|
-
registerTestCleanup(() => {
|
|
106
|
-
vi.useRealSystemTime();
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Travel TO a specific moment. Equivalent to `freeze(target)` —
|
|
112
|
-
* exists as an explicit verb so "travel to 2026" reads better than
|
|
113
|
-
* "freeze at 2026" in tests.
|
|
114
|
-
*/
|
|
115
|
-
export function travelTo(target: TimeInput): void {
|
|
116
|
-
freeze(target);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Restore the real system clock immediately. Idempotent — safe to
|
|
121
|
-
* call when not currently frozen. Auto-restore still fires at
|
|
122
|
-
* end-of-test, so calling `unfreeze` early is purely a
|
|
123
|
-
* test-readability choice.
|
|
124
|
-
*/
|
|
125
|
-
export function unfreeze(): void {
|
|
126
|
-
vi.useRealSystemTime();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Read the currently frozen epoch, or `null` when running on real
|
|
131
|
-
* time. Useful for tests that compose `freeze` with their own clock
|
|
132
|
-
* arithmetic.
|
|
133
|
-
*/
|
|
134
|
-
export function frozenAt(): number | null {
|
|
135
|
-
return vi.getMockedSystemTime();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function requireTestContext(verb: string): void {
|
|
139
|
-
if (!inTestContext()) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`helix.time.${verb}: must be called inside a test (no active test frame). Calls from top-level setup leak across tests — use vi.setSystemTime directly with manual cleanup if that is what you want.`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function inputToMs(input: TimeInput): number {
|
|
147
|
-
let ms: number;
|
|
148
|
-
if (typeof input === "number") {
|
|
149
|
-
ms = input;
|
|
150
|
-
} else if (input instanceof Date) {
|
|
151
|
-
ms = input.getTime();
|
|
152
|
-
} else if (typeof input === "string") {
|
|
153
|
-
ms = Date.parse(input);
|
|
154
|
-
if (Number.isNaN(ms)) {
|
|
155
|
-
throw new Error(
|
|
156
|
-
`helix.time: cannot parse '${input}' as a date — pass an ISO 8601 string, Date, number (epoch ms), or an object with toMillis().`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
} else if (
|
|
160
|
-
typeof input === "object" &&
|
|
161
|
-
input !== null &&
|
|
162
|
-
typeof (input as MillisLike).toMillis === "function"
|
|
163
|
-
) {
|
|
164
|
-
const result = (input as MillisLike).toMillis();
|
|
165
|
-
if (typeof result !== "number") {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`helix.time: input.toMillis() must return a number, got ${typeof result}.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
ms = result;
|
|
171
|
-
} else {
|
|
172
|
-
throw new Error(
|
|
173
|
-
`helix.time: unsupported input type ${typeof input}. Use string / Date / number / { toMillis() }.`,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
requireInDateRange(ms, "freeze");
|
|
177
|
-
return ms;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function requireInDateRange(ms: number, verb: string): void {
|
|
181
|
-
if (!Number.isFinite(ms)) {
|
|
182
|
-
throw new Error(
|
|
183
|
-
`helix.time.${verb}: epoch must be finite, got ${ms}. (Did you pass an Invalid Date or a NaN?)`,
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
if (Math.abs(ms) > JS_DATE_MAX) {
|
|
187
|
-
throw new Error(
|
|
188
|
-
`helix.time.${verb}: epoch ${ms} exceeds the JS Date range (±8.64e15 ms). The resulting clock would be 'Invalid Date'.`,
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function applyDelta(base: number, amount: number, unit: TimeUnit): number {
|
|
194
|
-
switch (unit) {
|
|
195
|
-
case "ms":
|
|
196
|
-
return base + amount;
|
|
197
|
-
case "second":
|
|
198
|
-
return base + amount * 1_000;
|
|
199
|
-
case "minute":
|
|
200
|
-
return base + amount * 60_000;
|
|
201
|
-
case "hour":
|
|
202
|
-
return base + amount * 3_600_000;
|
|
203
|
-
case "day":
|
|
204
|
-
return base + amount * 86_400_000;
|
|
205
|
-
case "week":
|
|
206
|
-
return base + amount * 604_800_000;
|
|
207
|
-
case "month":
|
|
208
|
-
case "year": {
|
|
209
|
-
// UTC calendar math — JS `Date` mutators handle month
|
|
210
|
-
// overflow (e.g. month 13 → year+1 month 1) and clamp
|
|
211
|
-
// month-end days (e.g. Jan 31 + 1 month → Feb 28/29).
|
|
212
|
-
const d = new Date(base);
|
|
213
|
-
if (unit === "year") {
|
|
214
|
-
d.setUTCFullYear(d.getUTCFullYear() + amount);
|
|
215
|
-
} else {
|
|
216
|
-
d.setUTCMonth(d.getUTCMonth() + amount);
|
|
217
|
-
}
|
|
218
|
-
return d.getTime();
|
|
219
|
-
}
|
|
220
|
-
default: {
|
|
221
|
-
// Defensive — TS narrows this branch to `never`, but a
|
|
222
|
-
// runtime cast (`unit as TimeUnit`) could land here.
|
|
223
|
-
const exhaustive: never = unit;
|
|
224
|
-
throw new Error(
|
|
225
|
-
`helix.time.travel: unknown unit '${exhaustive as string}'.`,
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
package/src/time/index.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@c9up/helix/time` — time freeze / travel for tests.
|
|
3
|
-
*
|
|
4
|
-
* Pins `Date.now()`, `new Date()`, and `DateTime.now()` to a fake
|
|
5
|
-
* epoch. Auto-restored after each test via the per-test frame.
|
|
6
|
-
*
|
|
7
|
-
* import { time } from "@c9up/helix";
|
|
8
|
-
*
|
|
9
|
-
* time.freeze("2026-01-01");
|
|
10
|
-
* // ... test that depends on a fixed clock
|
|
11
|
-
* time.travel(7, "day");
|
|
12
|
-
* expect(DateTime.now().toISO()).toMatch(/^2026-01-08/);
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type { TimeUnit } from "./freeze.js";
|
|
16
|
-
export { freeze, frozenAt, travel, travelTo, unfreeze } from "./freeze.js";
|