@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.4

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.
@@ -0,0 +1,362 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: a clock a test controls.
4
+ //
5
+ // A test about "after five minutes the session expires" should not take five
6
+ // minutes, and a test that waits a real 50ms to see a debounce is a test that
7
+ // fails on a loaded machine. Fake timers replace the scheduling globals with a
8
+ // queue the test advances by hand, so elapsed time becomes an input rather than
9
+ // something to wait for.
10
+ //
11
+ // # What is faked
12
+ //
13
+ // `setTimeout`, `setInterval`, `setImmediate` and their `clear` partners, plus
14
+ // `Date.now` and `new Date()` with no arguments. Not `queueMicrotask` and not
15
+ // promises: a microtask is not scheduled *in time*, it runs at the end of the
16
+ // current turn, and pretending otherwise would let a test claim a promise
17
+ // resolved "after 100ms" when the two have nothing to do with each other. The
18
+ // async advance methods are the honest way to let microtasks run in between.
19
+ //
20
+ // # Why the ids are numbers
21
+ //
22
+ // Node's `setTimeout` returns a `Timeout` object and a browser's returns a
23
+ // number, and code written for either passes what it got straight to
24
+ // `clearTimeout`. A number works in both places — `clearTimeout` only ever
25
+ // looks the value up — and a test that stores one in a `Map` keyed by number
26
+ // keeps working.
27
+
28
+ /** One scheduled callback. */
29
+ type Task = {
30
+ readonly id: number,
31
+ /** When it is due, on the fake clock. */
32
+ due: number,
33
+ /** How often it repeats, or `null` for a one-shot. */
34
+ readonly every: number | null,
35
+ readonly body: (...args: $ReadOnlyArray<mixed>) => mixed,
36
+ readonly args: $ReadOnlyArray<mixed>,
37
+ /** Ordering among tasks due at the same instant: first scheduled, first run. */
38
+ readonly sequence: number,
39
+ };
40
+
41
+ /** The globals a clock replaces, so they can be put back exactly. */
42
+ type Saved = {
43
+ readonly setTimeout: mixed,
44
+ readonly clearTimeout: mixed,
45
+ readonly setInterval: mixed,
46
+ readonly clearInterval: mixed,
47
+ readonly setImmediate: mixed,
48
+ readonly clearImmediate: mixed,
49
+ readonly Date: mixed,
50
+ };
51
+
52
+ /**
53
+ * How many times a run loop will fire a timer before deciding it will not stop.
54
+ *
55
+ * An interval reschedules itself, so `runAllTimers` on one would never finish.
56
+ * A bound turns an unbounded hang into a failure that names the problem, which
57
+ * is the difference between a test suite that stops and one that has to be
58
+ * killed.
59
+ */
60
+ const RUNAWAY_LIMIT = 10_000;
61
+
62
+ let installed: Saved | null = null;
63
+ let now = 0;
64
+ let nextId = 1;
65
+ let sequence = 0;
66
+ let tasks: Array<Task> = [];
67
+
68
+ /** Whether a clock is currently installed. */
69
+ export function isFaked(): boolean {
70
+ return installed != null;
71
+ }
72
+
73
+ /** The globals to patch, in one place so install and restore cannot diverge. */
74
+ function host(): $FlowFixMe {
75
+ return globalThis;
76
+ }
77
+
78
+ /**
79
+ * Replace the scheduling globals with a clock this module controls.
80
+ *
81
+ * `now` starts at the real current time rather than at zero, so a test that
82
+ * formats a date sees a plausible one — and `setSystemTime` is how a test that
83
+ * cares says which.
84
+ */
85
+ export function useFakeTimers(): void {
86
+ if (installed != null) {
87
+ return;
88
+ }
89
+ const global = host();
90
+ installed = {
91
+ setTimeout: global.setTimeout,
92
+ clearTimeout: global.clearTimeout,
93
+ setInterval: global.setInterval,
94
+ clearInterval: global.clearInterval,
95
+ setImmediate: global.setImmediate,
96
+ clearImmediate: global.clearImmediate,
97
+ Date: global.Date,
98
+ };
99
+
100
+ now = Date.now();
101
+ tasks = [];
102
+ nextId = 1;
103
+ sequence = 0;
104
+
105
+ global.setTimeout = (body: $FlowFixMe, delay?: number, ...args: $ReadOnlyArray<mixed>) =>
106
+ schedule(body, delay ?? 0, null, args);
107
+ global.setInterval = (body: $FlowFixMe, delay?: number, ...args: $ReadOnlyArray<mixed>) =>
108
+ // A zero-delay interval would be scheduled at the same instant forever, so
109
+ // it advances by one tick — which is what every runtime does with it.
110
+ schedule(body, delay ?? 0, Math.max(delay ?? 0, 1), args);
111
+ // `setImmediate` is "before any timer, after this turn", which on a fake
112
+ // clock is a zero-delay timer that sorts ahead by having been scheduled at
113
+ // the current instant.
114
+ global.setImmediate = (body: $FlowFixMe, ...args: $ReadOnlyArray<mixed>) =>
115
+ schedule(body, 0, null, args);
116
+ global.clearTimeout = cancel;
117
+ global.clearInterval = cancel;
118
+ global.clearImmediate = cancel;
119
+ global.Date = fakeDate(installed.Date as $FlowFixMe);
120
+ }
121
+
122
+ /** Put the real scheduling globals back. */
123
+ export function useRealTimers(): void {
124
+ if (installed == null) {
125
+ return;
126
+ }
127
+ const global = host();
128
+ global.setTimeout = installed.setTimeout;
129
+ global.clearTimeout = installed.clearTimeout;
130
+ global.setInterval = installed.setInterval;
131
+ global.clearInterval = installed.clearInterval;
132
+ global.setImmediate = installed.setImmediate;
133
+ global.clearImmediate = installed.clearImmediate;
134
+ global.Date = installed.Date;
135
+ installed = null;
136
+ tasks = [];
137
+ }
138
+
139
+ /**
140
+ * A `Date` whose "now" is the fake clock's.
141
+ *
142
+ * A proxy over the real constructor rather than a subclass of it, and the
143
+ * difference is three bugs rather than a preference:
144
+ *
145
+ * * `Date()` — called as a function, with no `new` — returns a string in every
146
+ * runtime. A `class` cannot be called that way at all, so a subclass turned
147
+ * every such call into a `TypeError`, under fake timers only.
148
+ * * `before instanceof Date`, for a date built *before* the clock was faked,
149
+ * was false: the object's prototype chain runs through the real `Date` and a
150
+ * subclass's prototype is not in it. Anything branching on that — including
151
+ * `setSystemTime` below, once — silently took the wrong branch. A proxy has
152
+ * no prototype of its own, so the check is the real one.
153
+ * * `Date.name` was `"FakeDate"`, and a subclass's own `toString` reads as
154
+ * class source rather than native code.
155
+ *
156
+ * Only `now` is replaced. `parse`, `UTC`, and every prototype method are the
157
+ * real ones, reached through the proxy.
158
+ */
159
+ function fakeDate(Real: $FlowFixMe): $FlowFixMe {
160
+ return new Proxy(Real, {
161
+ // `Date(anything)` ignores its arguments and returns the current time as a
162
+ // string, which on this clock is the fake one.
163
+ apply(): string {
164
+ return new Real(now).toString();
165
+ },
166
+
167
+ construct(target: $FlowFixMe, args: $ReadOnlyArray<mixed>, newTarget: $FlowFixMe) {
168
+ // Only the no-argument form reads the clock; every other form is
169
+ // constructing a specific date and has nothing to do with "now".
170
+ const actual = args.length === 0 ? [now] : args;
171
+ // `newTarget` rather than `Real`, so a subclass of the faked `Date` gets
172
+ // its own prototype instead of the real one's.
173
+ return Reflect.construct(Real, actual, newTarget === undefined ? Real : newTarget);
174
+ },
175
+
176
+ get(target: $FlowFixMe, property: $FlowFixMe, receiver: $FlowFixMe): $FlowFixMe {
177
+ if (property === "now") {
178
+ return fakeNow;
179
+ }
180
+ return Reflect.get(target, property, receiver);
181
+ },
182
+ });
183
+ }
184
+
185
+ /** `Date.now`, as its own function so the proxy hands back a stable identity. */
186
+ function fakeNow(): number {
187
+ return now;
188
+ }
189
+
190
+ /** Put a task on the queue and hand back its id. */
191
+ function schedule(
192
+ body: $FlowFixMe,
193
+ delay: number,
194
+ every: number | null,
195
+ args: $ReadOnlyArray<mixed>,
196
+ ): number {
197
+ const id = nextId;
198
+ nextId += 1;
199
+ sequence += 1;
200
+ tasks.push({
201
+ id,
202
+ due: now + Math.max(delay, 0),
203
+ every,
204
+ body,
205
+ args,
206
+ sequence,
207
+ });
208
+ return id;
209
+ }
210
+
211
+ /** Take a task off the queue. Unknown ids are ignored, as the real ones are. */
212
+ function cancel(id: mixed): void {
213
+ tasks = tasks.filter((task) => task.id !== id);
214
+ }
215
+
216
+ /** How many timers are waiting. */
217
+ export function getTimerCount(): number {
218
+ return tasks.length;
219
+ }
220
+
221
+ /** The next task due, or `null`. Ties break by scheduling order. */
222
+ function nextTask(before: number): Task | null {
223
+ let best: Task | null = null;
224
+ for (const task of tasks) {
225
+ if (task.due > before) {
226
+ continue;
227
+ }
228
+ if (
229
+ best == null ||
230
+ task.due < best.due ||
231
+ (task.due === best.due && task.sequence < best.sequence)
232
+ ) {
233
+ best = task;
234
+ }
235
+ }
236
+ return best;
237
+ }
238
+
239
+ /** Run one task, rescheduling it when it repeats. */
240
+ function fire(task: Task): void {
241
+ now = task.due;
242
+ if (task.every == null) {
243
+ cancel(task.id);
244
+ } else {
245
+ task.due = now + task.every;
246
+ }
247
+ task.body(...task.args);
248
+ }
249
+
250
+ /** Raised when a run loop will not terminate. */
251
+ export class RunawayTimersError extends Error {
252
+ constructor(method: string) {
253
+ super(
254
+ `uf.${method}: still firing after ${RUNAWAY_LIMIT} timers. ` +
255
+ "A timer that reschedules itself never drains — advance the clock by a " +
256
+ "fixed amount instead.",
257
+ );
258
+ this.name = "RunawayTimersError";
259
+ }
260
+ }
261
+
262
+ /** Move the clock forward, firing everything that comes due. */
263
+ export function advanceTimersByTime(millis: number): void {
264
+ const target = now + Math.max(millis, 0);
265
+ let fired = 0;
266
+ for (;;) {
267
+ const task = nextTask(target);
268
+ if (task == null) {
269
+ break;
270
+ }
271
+ fired += 1;
272
+ if (fired > RUNAWAY_LIMIT) {
273
+ throw new RunawayTimersError("advanceTimersByTime");
274
+ }
275
+ fire(task);
276
+ }
277
+ // Land on the requested instant even when nothing was due there, so two
278
+ // advances of 50ms are the same as one of 100ms.
279
+ now = target;
280
+ }
281
+
282
+ /** The same, yielding to the microtask queue between timers. */
283
+ export async function advanceTimersByTimeAsync(millis: number): Promise<void> {
284
+ const target = now + Math.max(millis, 0);
285
+ let fired = 0;
286
+ for (;;) {
287
+ const task = nextTask(target);
288
+ if (task == null) {
289
+ break;
290
+ }
291
+ fired += 1;
292
+ if (fired > RUNAWAY_LIMIT) {
293
+ throw new RunawayTimersError("advanceTimersByTimeAsync");
294
+ }
295
+ fire(task);
296
+ // The point of the async form: a callback that awaited something gets to
297
+ // continue before the next timer fires.
298
+ await Promise.resolve();
299
+ }
300
+ now = target;
301
+ }
302
+
303
+ /** Fire the next timer due, whenever it is due. */
304
+ export function advanceTimersToNextTimer(): void {
305
+ const task = nextTask(Number.POSITIVE_INFINITY);
306
+ if (task != null) {
307
+ fire(task);
308
+ }
309
+ }
310
+
311
+ /** Fire everything until the queue is empty. */
312
+ export function runAllTimers(): void {
313
+ let fired = 0;
314
+ for (;;) {
315
+ const task = nextTask(Number.POSITIVE_INFINITY);
316
+ if (task == null) {
317
+ break;
318
+ }
319
+ fired += 1;
320
+ if (fired > RUNAWAY_LIMIT) {
321
+ throw new RunawayTimersError("runAllTimers");
322
+ }
323
+ fire(task);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Fire only what is already queued, not what those callbacks schedule.
329
+ *
330
+ * The way to drain an interval once: `runAllTimers` on one never terminates,
331
+ * and this fires each waiting task exactly once.
332
+ */
333
+ export function runOnlyPendingTimers(): void {
334
+ const pending = [...tasks].sort((a, b) => a.due - b.due || a.sequence - b.sequence);
335
+ for (const task of pending) {
336
+ // A callback may have cancelled a later one, so check it is still queued.
337
+ if (tasks.some((queued) => queued.id === task.id)) {
338
+ fire(task);
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Move the wall clock without firing anything.
345
+ *
346
+ * Duck-typed rather than `instanceof Date`, and deliberately: `Date` here is
347
+ * whatever is installed, so the check would be asking about the *fake* one
348
+ * while the caller may be holding a date made before the clock was faked, or
349
+ * one from another realm. Either answered `false`, fell through to the number
350
+ * branch, and set the clock to an object — after which every comparison
351
+ * against it was `false` and no timer ever came due.
352
+ *
353
+ * Anything that can say what instant it is, is an instant.
354
+ */
355
+ export function setSystemTime(time: number | Date): void {
356
+ now = typeof time === "number" ? time : Number((time as $FlowFixMe).getTime());
357
+ }
358
+
359
+ /** What the fake clock currently reads. */
360
+ export function getMockedSystemTime(): Date | null {
361
+ return installed == null ? null : new (installed.Date as $FlowFixMe)(now);
362
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.1",
3
+ "version": "0.0.0-alpha.4",
4
4
  "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,5 +19,8 @@
19
19
  "index.js",
20
20
  "worker.js",
21
21
  "internal"
22
- ]
22
+ ],
23
+ "dependencies": {
24
+ "@uniflowed/host": "0.0.0-alpha.4"
25
+ }
23
26
  }
package/worker.js CHANGED
@@ -9,32 +9,59 @@
9
9
  //
10
10
  // → {"file": "src/math.test.js", "filter": "adds", "timeoutMs": 5000}
11
11
  // ← {"event": "test", "name": "math > adds", "status": "passed", …}
12
+ // ← {"event": "output", "stream": "stdout", "test": "math > adds", "text": "hi\n"}
12
13
  // ← {"event": "file", "status": "completed", "durationMicros": 1234}
13
14
  //
14
- // Two decisions worth stating. Results are streamed as they happen rather than
15
- // batched at the end, so `uf test` can draw progress and `--bail` can stop a
16
- // long run early. And a file that throws while being *imported* is a file
17
- // result, not a test result: there were no tests to fail, and saying "0 tests"
18
- // for a module that could not load would be a lie.
15
+ // Three decisions worth stating. Results are streamed as they happen rather
16
+ // than batched at the end, so `uf test` can draw progress and `--bail` can stop
17
+ // a long run early. A file that throws while being *imported* is a file result,
18
+ // not a test result: there were no tests to fail, and saying "0 tests" for a
19
+ // module that could not load would be a lie. And the protocol does not share
20
+ // its stream with the tests: a test's own printing becomes an `output` event
21
+ // (`internal/output.js`), so a `console.log` cannot land in the middle of a
22
+ // line `uf` is parsing.
19
23
  //
20
24
  // This module runs on import by design — it is a process entry point, the way
21
25
  // `@uniflowed/vite`'s loaders are.
22
26
 
27
+ import * as output from "./internal/output.js";
28
+ import { writeChangedSnapshots } from "./internal/snapshot.js";
23
29
  import { createInterface } from "node:readline";
24
- import { pathToFileURL } from "node:url";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
31
 
26
32
  import { reset } from "./internal/registry.js";
27
33
  import { run } from "./internal/run.js";
28
34
 
29
35
  /** What `uf` sends for one file. */
30
36
  type Request = {|
31
- +file: string,
32
- +filter?: string | null,
33
- +timeoutMs?: number,
37
+ readonly file: string,
38
+ readonly filter?: string | null,
39
+ readonly timeoutMs?: number,
34
40
  |};
35
41
 
36
- function write(event: { +[string]: mixed }): void {
37
- process.stdout.write(`${JSON.stringify(event)}\n`);
42
+ /**
43
+ * The protocol's own stdout, and the capture that gave it up.
44
+ *
45
+ * A test can reach `console.log` and `process.stdout.write`, and both used to
46
+ * land in the middle of a line `uf` was parsing. `internal/output.js` takes
47
+ * those over and hands back the real write it replaced, so the protocol has a
48
+ * stream nothing else can reach and a test's printing is still reported —
49
+ * as an `output` event, escaped, whatever it says.
50
+ *
51
+ * At module scope, before a single test file can be imported, so output
52
+ * written while a file is still loading is reported rather than lost.
53
+ */
54
+ const emit: (chunk: string) => void = output.install((chunk) => {
55
+ write({
56
+ event: "output",
57
+ stream: chunk.stream,
58
+ test: chunk.test,
59
+ text: chunk.text,
60
+ });
61
+ });
62
+
63
+ function write(event: { readonly [string]: mixed }): void {
64
+ emit(`${JSON.stringify(event)}\n`);
38
65
  }
39
66
 
40
67
  /**
@@ -47,6 +74,7 @@ function write(event: { +[string]: mixed }): void {
47
74
  async function runFile(request: Request, generation: number): Promise<void> {
48
75
  const started = performance.now();
49
76
  reset();
77
+ output.startFile();
50
78
 
51
79
  try {
52
80
  await import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
@@ -63,9 +91,24 @@ async function runFile(request: Request, generation: number): Promise<void> {
63
91
  }
64
92
 
65
93
  try {
66
- await run({ filter: request.filter ?? null, timeoutMs: request.timeoutMs }, (result) => {
67
- write({ event: "test", ...result.outcome, name: result.name, line: result.line, column: result.column, durationMicros: result.durationMicros });
68
- });
94
+ const absolute = fileURLToPath(pathToFileURL(request.file).href);
95
+ await run(
96
+ { filter: request.filter ?? null, timeoutMs: request.timeoutMs, file: absolute },
97
+ (result) => {
98
+ write({
99
+ event: "test",
100
+ ...result.outcome,
101
+ name: result.name,
102
+ line: result.line,
103
+ column: result.column,
104
+ durationMicros: result.durationMicros,
105
+ });
106
+ },
107
+ );
108
+ // Once, at the end of the file, rather than after each snapshot: a file
109
+ // with forty snapshots would otherwise rewrite its snapshot file forty
110
+ // times, and a crash halfway through would leave a partial one.
111
+ writeChangedSnapshots();
69
112
  write({
70
113
  event: "file",
71
114
  status: "completed",
@@ -103,7 +146,11 @@ function serve(): void {
103
146
  try {
104
147
  request = JSON.parse(line);
105
148
  } catch (error) {
106
- write({ event: "file", status: "run-failed", message: `malformed request: ${String(error)}` });
149
+ write({
150
+ event: "file",
151
+ status: "run-failed",
152
+ message: `malformed request: ${String(error)}`,
153
+ });
107
154
  return;
108
155
  }
109
156
  generation += 1;