@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.11
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/index.js +21 -2
- package/internal/asymmetric.js +184 -0
- package/internal/equality.js +79 -19
- package/internal/expect.js +323 -81
- package/internal/frames.js +96 -4
- package/internal/modules.js +430 -0
- package/internal/namespace.js +287 -0
- package/internal/output.js +309 -0
- package/internal/registry.js +30 -27
- package/internal/run.js +94 -42
- package/internal/snapshot.js +306 -0
- package/internal/spy.js +236 -0
- package/internal/timers.js +362 -0
- package/internal/unsupported.js +30 -0
- package/package.json +5 -2
- package/worker.js +163 -25
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: the `uft` namespace.
|
|
4
|
+
//
|
|
5
|
+
// The operations Vitest groups under `vi`, under uf's own name. The *shape* is
|
|
6
|
+
// what a migration needs — `fn`, `spyOn`, `stubEnv`, `useFakeTimers` doing what
|
|
7
|
+
// they do elsewhere — and borrowing another tool's brand for it would be
|
|
8
|
+
// claiming something uf has not earned.
|
|
9
|
+
//
|
|
10
|
+
// A namespace rather than loose named exports, because several of these names
|
|
11
|
+
// are generic enough to collide in a test file: `@uniflowed/testing` re-exports
|
|
12
|
+
// both this package and `@uniflowed/react-testing`, and both have a `waitFor`.
|
|
13
|
+
//
|
|
14
|
+
// `uft` rather than `uf`, and rather than `uf.test`. A bare `uf` is the command
|
|
15
|
+
// and the project, and a test file would be using the name for something much
|
|
16
|
+
// smaller than everything else in the toolchain answers to. `uf.test` reads as
|
|
17
|
+
// the `test` function this package also exports, which is `it` under another
|
|
18
|
+
// name — two very different things one dot apart. `uft` is three characters,
|
|
19
|
+
// belongs to nothing else, and is what a reader types a hundred times a file.
|
|
20
|
+
//
|
|
21
|
+
// `uft.mock` and the six names beside it intercept a module before it is
|
|
22
|
+
// imported, which is the loader's job rather than the runner's: by the time the
|
|
23
|
+
// runner sees an `import`, the module has been fetched, linked and evaluated.
|
|
24
|
+
// So the mechanism is `@uniflowed/host`'s (`module-mocks.js`) and the API is
|
|
25
|
+
// `./modules.js`'s, and this file only names them. A host that cannot provide
|
|
26
|
+
// synchronous module hooks gets an `UnsupportedError` that says which host it
|
|
27
|
+
// is and what to do instead — never a binding that silently does nothing.
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
importActual as importActualModule,
|
|
31
|
+
importMock as importMockModule,
|
|
32
|
+
mock as mockModule,
|
|
33
|
+
resetModules as resetModulesNow,
|
|
34
|
+
unmock as unmockModule,
|
|
35
|
+
} from "./modules.js";
|
|
36
|
+
import { clearAllMocks, fn, resetAllMocks, restoreAllMocks, spyOn } from "./spy.js";
|
|
37
|
+
import * as timers from "./timers.js";
|
|
38
|
+
import { UnsupportedError } from "./unsupported.js";
|
|
39
|
+
|
|
40
|
+
// Declared in `./unsupported.js` rather than here, because `./modules.js` needs
|
|
41
|
+
// it too and a class both halves of a pair reach for is a third module.
|
|
42
|
+
export { UnsupportedError } from "./unsupported.js";
|
|
43
|
+
|
|
44
|
+
/** Environment variables `stubEnv` replaced, and what they were. */
|
|
45
|
+
const stubbedEnv: Map<string, string | void> = new Map();
|
|
46
|
+
|
|
47
|
+
/** Globals `stubGlobal` replaced, and what they were. */
|
|
48
|
+
const stubbedGlobals: Map<string, { readonly owned: boolean, readonly value: mixed }> = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the process environment, whichever host this is.
|
|
52
|
+
*
|
|
53
|
+
* Node, Deno and Bun all expose `process.env`; Deno also has `Deno.env`, and
|
|
54
|
+
* reaching for `process` first keeps one code path across the three.
|
|
55
|
+
*/
|
|
56
|
+
function environment(): { [string]: string } | null {
|
|
57
|
+
const host = globalThis as $FlowFixMe;
|
|
58
|
+
return host.process?.env ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace an environment variable for the rest of the test.
|
|
63
|
+
*
|
|
64
|
+
* Undone by `unstubAllEnvs`, which the runner calls between files — a stub that
|
|
65
|
+
* outlived its test would be a test that passes alone and fails in a suite.
|
|
66
|
+
*/
|
|
67
|
+
export function stubEnv(name: string, value: string | void): void {
|
|
68
|
+
const env = environment();
|
|
69
|
+
if (env == null) {
|
|
70
|
+
throw new UnsupportedError("stubEnv", "this host exposes no process environment");
|
|
71
|
+
}
|
|
72
|
+
if (!stubbedEnv.has(name)) {
|
|
73
|
+
stubbedEnv.set(name, Object.hasOwn(env, name) ? env[name] : undefined);
|
|
74
|
+
}
|
|
75
|
+
if (value === undefined) {
|
|
76
|
+
delete env[name];
|
|
77
|
+
} else {
|
|
78
|
+
env[name] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Put every environment variable `stubEnv` replaced back. */
|
|
83
|
+
export function unstubAllEnvs(): void {
|
|
84
|
+
const env = environment();
|
|
85
|
+
if (env == null) {
|
|
86
|
+
stubbedEnv.clear();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const [name, previous] of stubbedEnv) {
|
|
90
|
+
if (previous === undefined) {
|
|
91
|
+
delete env[name];
|
|
92
|
+
} else {
|
|
93
|
+
env[name] = previous;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
stubbedEnv.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Replace a global for the rest of the test.
|
|
101
|
+
*
|
|
102
|
+
* Whether the global was the object's own property is recorded, because putting
|
|
103
|
+
* back an inherited one by assignment would leave a copy that shadows whatever
|
|
104
|
+
* it was inherited from.
|
|
105
|
+
*/
|
|
106
|
+
export function stubGlobal(name: string, value: mixed): void {
|
|
107
|
+
const host = globalThis as $FlowFixMe;
|
|
108
|
+
if (!stubbedGlobals.has(name)) {
|
|
109
|
+
stubbedGlobals.set(name, {
|
|
110
|
+
owned: Object.hasOwn(host, name),
|
|
111
|
+
value: host[name],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
host[name] = value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Put every global `stubGlobal` replaced back. */
|
|
118
|
+
export function unstubAllGlobals(): void {
|
|
119
|
+
const host = globalThis as $FlowFixMe;
|
|
120
|
+
for (const [name, previous] of stubbedGlobals) {
|
|
121
|
+
if (previous.owned) {
|
|
122
|
+
host[name] = previous.value;
|
|
123
|
+
} else {
|
|
124
|
+
delete host[name];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
stubbedGlobals.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How often `waitFor` re-runs its body while it is failing. */
|
|
131
|
+
const WAIT_INTERVAL_MS = 20;
|
|
132
|
+
|
|
133
|
+
/** How long `waitFor` keeps trying before giving up. */
|
|
134
|
+
const WAIT_TIMEOUT_MS = 1_000;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run `body` until it stops throwing, or the timeout passes.
|
|
138
|
+
*
|
|
139
|
+
* The last failure is what is raised, not a timeout — "expected 2, got 1" says
|
|
140
|
+
* what went wrong, and "timed out" says only that something did.
|
|
141
|
+
*/
|
|
142
|
+
export async function waitFor<T>(
|
|
143
|
+
body: () => T | Promise<T>,
|
|
144
|
+
options?: { readonly timeout?: number, readonly interval?: number },
|
|
145
|
+
): Promise<T> {
|
|
146
|
+
const timeout = options?.timeout ?? WAIT_TIMEOUT_MS;
|
|
147
|
+
const interval = options?.interval ?? WAIT_INTERVAL_MS;
|
|
148
|
+
const deadline = Date.now() + timeout;
|
|
149
|
+
let last: mixed = null;
|
|
150
|
+
|
|
151
|
+
for (;;) {
|
|
152
|
+
try {
|
|
153
|
+
return await body();
|
|
154
|
+
} catch (thrown) {
|
|
155
|
+
last = thrown;
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() >= deadline) {
|
|
158
|
+
throw last ?? new Error(`uft.waitFor: gave up after ${timeout}ms`);
|
|
159
|
+
}
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Run `body` until it returns something truthy, or the timeout passes. */
|
|
165
|
+
export async function waitUntil(
|
|
166
|
+
body: () => mixed | Promise<mixed>,
|
|
167
|
+
options?: { readonly timeout?: number, readonly interval?: number },
|
|
168
|
+
): Promise<mixed> {
|
|
169
|
+
return waitFor(async () => {
|
|
170
|
+
const value = await body();
|
|
171
|
+
if (value == null || value === false) {
|
|
172
|
+
throw new Error("uft.waitUntil: the condition is not true yet");
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}, options);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Hand a value back with its mock methods visible to the type checker.
|
|
180
|
+
*
|
|
181
|
+
* Purely a type-level convenience, exactly as in Vitest: at runtime it is the
|
|
182
|
+
* identity function, and its whole job is letting a test write
|
|
183
|
+
* `uft.mocked(client.send).mockReturnValue(…)` without a cast.
|
|
184
|
+
*/
|
|
185
|
+
export function mocked<T>(value: T): $FlowFixMe {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The `uft` namespace's type.
|
|
191
|
+
*
|
|
192
|
+
* Written out member by member rather than left as one `$FlowFixMe`, because
|
|
193
|
+
* the module-mocking half of it is the half a type can genuinely check: a
|
|
194
|
+
* factory that hands back the wrong shape for the module it is standing in for
|
|
195
|
+
* is an error at the call site, and that only works if `uft` has a type at all.
|
|
196
|
+
* The members that were already typed loosely keep the types they have —
|
|
197
|
+
* `typeof` reads them from their definitions, so this list cannot drift from
|
|
198
|
+
* them.
|
|
199
|
+
*/
|
|
200
|
+
export type Uft = {
|
|
201
|
+
readonly fn: typeof fn,
|
|
202
|
+
readonly spyOn: typeof spyOn,
|
|
203
|
+
readonly mocked: typeof mocked,
|
|
204
|
+
|
|
205
|
+
readonly clearAllMocks: typeof clearAllMocks,
|
|
206
|
+
readonly resetAllMocks: typeof resetAllMocks,
|
|
207
|
+
readonly restoreAllMocks: typeof restoreAllMocks,
|
|
208
|
+
|
|
209
|
+
readonly stubEnv: typeof stubEnv,
|
|
210
|
+
readonly unstubAllEnvs: typeof unstubAllEnvs,
|
|
211
|
+
readonly stubGlobal: typeof stubGlobal,
|
|
212
|
+
readonly unstubAllGlobals: typeof unstubAllGlobals,
|
|
213
|
+
|
|
214
|
+
readonly waitFor: typeof waitFor,
|
|
215
|
+
readonly waitUntil: typeof waitUntil,
|
|
216
|
+
|
|
217
|
+
readonly useFakeTimers: typeof timers.useFakeTimers,
|
|
218
|
+
readonly useRealTimers: typeof timers.useRealTimers,
|
|
219
|
+
readonly isFakeTimers: typeof timers.isFaked,
|
|
220
|
+
readonly advanceTimersByTime: typeof timers.advanceTimersByTime,
|
|
221
|
+
readonly advanceTimersByTimeAsync: typeof timers.advanceTimersByTimeAsync,
|
|
222
|
+
readonly advanceTimersToNextTimer: typeof timers.advanceTimersToNextTimer,
|
|
223
|
+
readonly runAllTimers: typeof timers.runAllTimers,
|
|
224
|
+
readonly runOnlyPendingTimers: typeof timers.runOnlyPendingTimers,
|
|
225
|
+
readonly getTimerCount: typeof timers.getTimerCount,
|
|
226
|
+
readonly setSystemTime: typeof timers.setSystemTime,
|
|
227
|
+
readonly getMockedSystemTime: typeof timers.getMockedSystemTime,
|
|
228
|
+
|
|
229
|
+
readonly mock: typeof mockModule,
|
|
230
|
+
readonly doMock: typeof mockModule,
|
|
231
|
+
readonly unmock: typeof unmockModule,
|
|
232
|
+
readonly doUnmock: typeof unmockModule,
|
|
233
|
+
readonly importActual: typeof importActualModule,
|
|
234
|
+
readonly importMock: typeof importMockModule,
|
|
235
|
+
readonly resetModules: typeof resetModulesNow,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The `uft` namespace.
|
|
240
|
+
*
|
|
241
|
+
* A frozen object rather than a class: it is a namespace, nothing about it is
|
|
242
|
+
* per-instance, and freezing it means a test cannot leave a monkey-patch behind
|
|
243
|
+
* for the next one.
|
|
244
|
+
*/
|
|
245
|
+
export const uft: Uft = Object.freeze({
|
|
246
|
+
fn,
|
|
247
|
+
spyOn,
|
|
248
|
+
mocked,
|
|
249
|
+
|
|
250
|
+
clearAllMocks,
|
|
251
|
+
resetAllMocks,
|
|
252
|
+
restoreAllMocks,
|
|
253
|
+
|
|
254
|
+
stubEnv,
|
|
255
|
+
unstubAllEnvs,
|
|
256
|
+
stubGlobal,
|
|
257
|
+
unstubAllGlobals,
|
|
258
|
+
|
|
259
|
+
waitFor,
|
|
260
|
+
waitUntil,
|
|
261
|
+
|
|
262
|
+
// The clock a test controls. A test about "after five minutes the session
|
|
263
|
+
// expires" should not take five minutes.
|
|
264
|
+
useFakeTimers: timers.useFakeTimers,
|
|
265
|
+
useRealTimers: timers.useRealTimers,
|
|
266
|
+
isFakeTimers: timers.isFaked,
|
|
267
|
+
advanceTimersByTime: timers.advanceTimersByTime,
|
|
268
|
+
advanceTimersByTimeAsync: timers.advanceTimersByTimeAsync,
|
|
269
|
+
advanceTimersToNextTimer: timers.advanceTimersToNextTimer,
|
|
270
|
+
runAllTimers: timers.runAllTimers,
|
|
271
|
+
runOnlyPendingTimers: timers.runOnlyPendingTimers,
|
|
272
|
+
getTimerCount: timers.getTimerCount,
|
|
273
|
+
setSystemTime: timers.setSystemTime,
|
|
274
|
+
getMockedSystemTime: timers.getMockedSystemTime,
|
|
275
|
+
|
|
276
|
+
// Module interception. `doMock` is `mock` and `doUnmock` is `unmock`, under
|
|
277
|
+
// the names Vitest gives the un-hoisted forms: there is one form here,
|
|
278
|
+
// because uf hoists neither, and a `doMock` that was a different function
|
|
279
|
+
// would be claiming a difference that does not exist.
|
|
280
|
+
mock: mockModule,
|
|
281
|
+
doMock: mockModule,
|
|
282
|
+
unmock: unmockModule,
|
|
283
|
+
doUnmock: unmockModule,
|
|
284
|
+
importActual: importActualModule,
|
|
285
|
+
importMock: importMockModule,
|
|
286
|
+
resetModules: resetModulesNow,
|
|
287
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: what a test printed, attributed to the test
|
|
4
|
+
// that printed it.
|
|
5
|
+
//
|
|
6
|
+
// The worker and the tests it runs share one stdout, and the worker's protocol
|
|
7
|
+
// is one JSON object per line on it. A `console.log` in a test therefore wrote
|
|
8
|
+
// a line into the middle of that protocol, `uf` read it as an event it could
|
|
9
|
+
// not parse, and the whole file died — a green suite killed by a debugging
|
|
10
|
+
// statement somebody left in.
|
|
11
|
+
//
|
|
12
|
+
// So the stream stops being shared. This module takes over `console` and the
|
|
13
|
+
// two `write` methods a test can reach directly, turns everything written
|
|
14
|
+
// through them into an ordinary protocol event, and hands the caller the real
|
|
15
|
+
// `process.stdout.write` it took — the protocol keeps the raw stream, and it
|
|
16
|
+
// is the only thing that has it. A test that prints something shaped exactly
|
|
17
|
+
// like a protocol line is then a string inside a JSON field, which is the
|
|
18
|
+
// point: the escaping is what makes the channel robust, not a filter that
|
|
19
|
+
// tries to recognise the imposter.
|
|
20
|
+
//
|
|
21
|
+
// # Why this is its own module
|
|
22
|
+
//
|
|
23
|
+
// Two callers need it and neither owns it: `worker.js` installs the capture at
|
|
24
|
+
// start-up, and `run.js` runs each case inside the ownership kept here, so a
|
|
25
|
+
// chunk can be named. "Who printed this" is also state with a lifetime of its
|
|
26
|
+
// own — one case's hooks and body — exactly like the snapshot key in
|
|
27
|
+
// `snapshot.js`, and for the same reason it lives beside the thing it describes
|
|
28
|
+
// rather than inside either caller.
|
|
29
|
+
//
|
|
30
|
+
// # What "the test that printed it" means
|
|
31
|
+
//
|
|
32
|
+
// The case whose *asynchronous context* the write happened in, which is the
|
|
33
|
+
// case whose code produced it.
|
|
34
|
+
//
|
|
35
|
+
// The obvious answer was a module-level variable the runner set before a case
|
|
36
|
+
// and cleared after it, and it was wrong in one shape that matters: the name a
|
|
37
|
+
// chunk carried was whatever the worker happened to be running when the chunk
|
|
38
|
+
// arrived. A `setTimeout` a test left behind fires while the *next* case is
|
|
39
|
+
// running, so the line it printed was reported under that next case — a test
|
|
40
|
+
// accused of printing something it never printed, which is worse than not
|
|
41
|
+
// naming it at all, because a reader chasing the message finds it under code
|
|
42
|
+
// that does not contain it. See ubugeeei-prod/uf#207.
|
|
43
|
+
//
|
|
44
|
+
// So the owner is an `AsyncLocalStorage`, and what `run.js` calls is
|
|
45
|
+
// `runInTest` rather than an `enterTest` / `exitTest` pair: the store is only
|
|
46
|
+
// carried by work started *inside* the case, so the case's setup, body and
|
|
47
|
+
// teardown have to run within it. Everything they schedule inherits it,
|
|
48
|
+
// whenever it eventually runs.
|
|
49
|
+
//
|
|
50
|
+
// # When there is no owner
|
|
51
|
+
//
|
|
52
|
+
// `getStore()` answers nothing outside a case, and a chunk with no owner is
|
|
53
|
+
// filed under the file — which is right for an import, a `beforeAll`, or a
|
|
54
|
+
// straggler from a case that is long gone.
|
|
55
|
+
//
|
|
56
|
+
// It is also the answer on a host whose storage does not reach the callback.
|
|
57
|
+
// Deno 1.31 has `AsyncLocalStorage` and propagates it across `await`, but not
|
|
58
|
+
// through `setTimeout`, so a detached callback there is filed under the file
|
|
59
|
+
// rather than under the case that scheduled it. That degradation is the point:
|
|
60
|
+
// of the two ways to be less than exact, naming the file says less, and naming
|
|
61
|
+
// the next case says something false.
|
|
62
|
+
//
|
|
63
|
+
// `node:async_hooks` itself is not guarded for, because a guard could not run.
|
|
64
|
+
// Node, Deno and Bun all provide it under the `node:` specifier, and a host
|
|
65
|
+
// that had no `node:` builtins could not start this worker at all — `node:util`
|
|
66
|
+
// is imported below, `node:readline` and `node:url` by `worker.js`. A `typeof`
|
|
67
|
+
// check around the constructor would only ever execute on a host where this
|
|
68
|
+
// module had already linked.
|
|
69
|
+
//
|
|
70
|
+
// The one thing this does not answer is a straggler that outlives its *file*:
|
|
71
|
+
// the worker runs the next file in the same process, and a chunk still carrying
|
|
72
|
+
// a name from the file before is a name the next file's report has no test for.
|
|
73
|
+
// The host files it under the file it arrived in, which is honest but not
|
|
74
|
+
// exact, and closing it properly needs the file's generation in the protocol.
|
|
75
|
+
// That is ubugeeei-prod/uf#203, and it is not this module's to fix.
|
|
76
|
+
|
|
77
|
+
// # Bounds
|
|
78
|
+
//
|
|
79
|
+
// A test that prints in a loop must not be able to fill the pipe, the worker's
|
|
80
|
+
// memory or the report, so one file's captured output is bounded and the
|
|
81
|
+
// bound is announced rather than hidden: the chunk that reaches it says so and
|
|
82
|
+
// nothing after it is kept. The budget starts over for each file, so a chatty
|
|
83
|
+
// file does not silence the next one in the same worker.
|
|
84
|
+
|
|
85
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
86
|
+
import { format, inspect } from "node:util";
|
|
87
|
+
|
|
88
|
+
import { userFrames } from "./frames.js";
|
|
89
|
+
|
|
90
|
+
/** Which of the process's two streams a chunk was written to. */
|
|
91
|
+
export type OutputStream = "stdout" | "stderr";
|
|
92
|
+
|
|
93
|
+
/** One thing a test — or the file around it — printed. */
|
|
94
|
+
export type OutputChunk = {|
|
|
95
|
+
readonly stream: OutputStream,
|
|
96
|
+
/** Full name of the case that was running, or `null` when none was. */
|
|
97
|
+
readonly test: string | null,
|
|
98
|
+
/** The text as it would have reached the terminal, newline included. */
|
|
99
|
+
readonly text: string,
|
|
100
|
+
|};
|
|
101
|
+
|
|
102
|
+
/** Where captured output goes. */
|
|
103
|
+
export type OutputSink = (chunk: OutputChunk) => void;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Longest single write kept, in UTF-16 code units.
|
|
107
|
+
*
|
|
108
|
+
* A test that prints a megabyte-long serialised fixture meant to print
|
|
109
|
+
* something; the first few kilobytes of it are what says what happened, and
|
|
110
|
+
* the rest is not a report's to carry.
|
|
111
|
+
*/
|
|
112
|
+
export const MAX_CHUNK_LENGTH: number = 8 * 1024;
|
|
113
|
+
|
|
114
|
+
/** Most output kept from one file, in UTF-16 code units. */
|
|
115
|
+
export const MAX_FILE_LENGTH: number = 128 * 1024;
|
|
116
|
+
|
|
117
|
+
/** Which stream each replaced `console` method writes to, as Node routes them. */
|
|
118
|
+
const CONSOLE_STREAMS: { readonly [string]: OutputStream } = {
|
|
119
|
+
log: "stdout",
|
|
120
|
+
info: "stdout",
|
|
121
|
+
debug: "stdout",
|
|
122
|
+
warn: "stderr",
|
|
123
|
+
error: "stderr",
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Decoder for a `write` that was handed bytes rather than a string. */
|
|
127
|
+
const DECODER = new TextDecoder();
|
|
128
|
+
|
|
129
|
+
/** What a stream's `write` calls when it has taken the chunk. */
|
|
130
|
+
type WriteCallback = () => mixed;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The case a write belongs to, kept in the asynchronous context it ran in.
|
|
134
|
+
*
|
|
135
|
+
* Full names rather than a record, because that is the whole of what a chunk
|
|
136
|
+
* needs to say and the protocol carries it as a string either way.
|
|
137
|
+
*/
|
|
138
|
+
const owner: AsyncLocalStorage<string> = new AsyncLocalStorage();
|
|
139
|
+
|
|
140
|
+
let sink: OutputSink | null = null;
|
|
141
|
+
let raw: ((chunk: string) => void) | null = null;
|
|
142
|
+
let captured = 0;
|
|
143
|
+
let stopped = false;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The globals to patch, in one place.
|
|
147
|
+
*
|
|
148
|
+
* The one untyped expression in this module, and it is the trust boundary
|
|
149
|
+
* itself: `console` and `process.stdout` are the host's, their libdef types
|
|
150
|
+
* are read-only, and replacing them is exactly what this module is for.
|
|
151
|
+
*/
|
|
152
|
+
function host(): $FlowFixMe {
|
|
153
|
+
return globalThis;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `args` rendered the way `console.log` renders them.
|
|
158
|
+
*
|
|
159
|
+
* Node's console hands its arguments to `util.format`, and this is that in two
|
|
160
|
+
* cases rather than one, because `format`'s first parameter is the template:
|
|
161
|
+
* with a leading string it substitutes `%s`, `%d`, `%o` and the rest and
|
|
162
|
+
* inspects whatever is left over, and with anything else there is no template
|
|
163
|
+
* to substitute into, so every argument is inspected and the results joined
|
|
164
|
+
* with a space. Both are what Node prints.
|
|
165
|
+
*/
|
|
166
|
+
function formatArguments(args: $ReadOnlyArray<mixed>): string {
|
|
167
|
+
const [first, ...rest] = args;
|
|
168
|
+
if (typeof first === "string") {
|
|
169
|
+
return format(first, ...rest);
|
|
170
|
+
}
|
|
171
|
+
return args.map((value) => (typeof value === "string" ? value : inspect(value))).join(" ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The text of one `write` argument, whether it arrived as bytes or a string. */
|
|
175
|
+
function textOf(chunk: mixed): string {
|
|
176
|
+
if (typeof chunk === "string") {
|
|
177
|
+
return chunk;
|
|
178
|
+
}
|
|
179
|
+
if (chunk instanceof Uint8Array) {
|
|
180
|
+
return DECODER.decode(chunk);
|
|
181
|
+
}
|
|
182
|
+
return String(chunk);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Record one piece of output, within the file's budget.
|
|
187
|
+
*
|
|
188
|
+
* Silently doing nothing when no sink is installed is deliberate: a test that
|
|
189
|
+
* prints must never fail because of how it was run.
|
|
190
|
+
*/
|
|
191
|
+
function capture(stream: OutputStream, text: string): void {
|
|
192
|
+
const to = sink;
|
|
193
|
+
if (to == null || stopped || text === "") {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
let kept = text.length > MAX_CHUNK_LENGTH ? `${text.slice(0, MAX_CHUNK_LENGTH)}…\n` : text;
|
|
197
|
+
const room = MAX_FILE_LENGTH - captured;
|
|
198
|
+
if (kept.length >= room) {
|
|
199
|
+
kept = `${kept.slice(0, Math.max(room, 0))}\n[uf] output stopped after ${MAX_FILE_LENGTH} characters\n`;
|
|
200
|
+
stopped = true;
|
|
201
|
+
}
|
|
202
|
+
captured += kept.length;
|
|
203
|
+
to({ stream, test: owner.getStore() ?? null, text: kept });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A stand-in for `process.stdout.write` / `process.stderr.write`. */
|
|
207
|
+
function writer(
|
|
208
|
+
stream: OutputStream,
|
|
209
|
+
): (chunk: mixed, encoding?: string | WriteCallback, callback?: WriteCallback) => boolean {
|
|
210
|
+
return (chunk, encoding, callback) => {
|
|
211
|
+
capture(stream, textOf(chunk));
|
|
212
|
+
// `write(chunk, callback)` and `write(chunk, encoding, callback)` are both
|
|
213
|
+
// real calls, and a caller that passed a callback is waiting for it.
|
|
214
|
+
//
|
|
215
|
+
// Deferred, because the real `Writable.write` never calls back before it
|
|
216
|
+
// returns: a caller that writes and then does something on the next line
|
|
217
|
+
// has that line run first, and one whose callback ran inline would see the
|
|
218
|
+
// two in the other order. `queueMicrotask` rather than `process.nextTick`
|
|
219
|
+
// so this holds on every host uf supports.
|
|
220
|
+
const done = typeof encoding === "function" ? encoding : callback;
|
|
221
|
+
if (done != null) {
|
|
222
|
+
queueMicrotask(done);
|
|
223
|
+
}
|
|
224
|
+
// The real method returns whether the stream has room for more. Nothing is
|
|
225
|
+
// buffered here, so it always has — and a caller told otherwise would wait
|
|
226
|
+
// for a `drain` that never comes.
|
|
227
|
+
return true;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Route everything a test prints to `to` instead of to the process's streams,
|
|
233
|
+
* and return the real `process.stdout.write` that was replaced.
|
|
234
|
+
*
|
|
235
|
+
* The caller gets the raw stream because the caller is the protocol, and a
|
|
236
|
+
* protocol sharing its stream with the code it reports on is the bug this
|
|
237
|
+
* module exists for. Handing it back from here rather than letting the caller
|
|
238
|
+
* read it first is what makes "taken before anything could have replaced it"
|
|
239
|
+
* true by construction.
|
|
240
|
+
*
|
|
241
|
+
* Installed once, for the life of the worker: a worker runs many files, and
|
|
242
|
+
* restoring the real methods between them would leave a window in which a
|
|
243
|
+
* straggling `setTimeout` from the previous file writes into the protocol.
|
|
244
|
+
*/
|
|
245
|
+
export function install(to: OutputSink): (chunk: string) => void {
|
|
246
|
+
const global = host();
|
|
247
|
+
const already = raw;
|
|
248
|
+
if (already != null) {
|
|
249
|
+
return already;
|
|
250
|
+
}
|
|
251
|
+
const stdout = global.process.stdout;
|
|
252
|
+
const real = stdout.write;
|
|
253
|
+
const protocol = (chunk: string) => {
|
|
254
|
+
real.call(stdout, chunk);
|
|
255
|
+
};
|
|
256
|
+
raw = protocol;
|
|
257
|
+
sink = to;
|
|
258
|
+
for (const method of Object.keys(CONSOLE_STREAMS)) {
|
|
259
|
+
const stream = CONSOLE_STREAMS[method];
|
|
260
|
+
global.console[method] = (...args: $ReadOnlyArray<mixed>) => {
|
|
261
|
+
capture(stream, `${formatArguments(args)}\n`);
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
global.console.trace = (...args: $ReadOnlyArray<mixed>) => {
|
|
265
|
+
// `console.trace` is a message *and* the stack under it, which is the
|
|
266
|
+
// whole reason to call it rather than `console.error`. `Error.stack`
|
|
267
|
+
// opens with `Trace: <message>`, so the trace Node prints is that string
|
|
268
|
+
// with this module's own frames taken off it.
|
|
269
|
+
const error = new Error(formatArguments(args));
|
|
270
|
+
error.name = "Trace";
|
|
271
|
+
capture("stderr", `${userFrames(error.stack) ?? `Trace: ${error.message}`}\n`);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
global.process.stdout.write = writer("stdout");
|
|
275
|
+
global.process.stderr.write = writer("stderr");
|
|
276
|
+
return protocol;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Run `body` as `name`, so what it prints — and what it leaves behind to print
|
|
281
|
+
* later — is filed under that case.
|
|
282
|
+
*
|
|
283
|
+
* The runner wraps one case's `beforeEach`, body and `afterEach` in a single
|
|
284
|
+
* call, because those are the one case's work. Whatever `body` returns is
|
|
285
|
+
* returned unchanged, so an `await` on this is an `await` on the case.
|
|
286
|
+
*
|
|
287
|
+
* Nothing here needs an "and now nothing is running" counterpart. Output from
|
|
288
|
+
* an import, a `beforeAll` or a case that has already been reported was never
|
|
289
|
+
* inside this call, so it has no owner and is the file's — which is the
|
|
290
|
+
* property the previous module-level variable had to be reset to keep, and
|
|
291
|
+
* kept only for as long as nothing straggled.
|
|
292
|
+
*/
|
|
293
|
+
export function runInTest<T>(name: string, body: () => T): T {
|
|
294
|
+
return owner.run(name, body);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Start one file's output budget over.
|
|
299
|
+
*
|
|
300
|
+
* Only the budget: there is no current case to clear, because a case's
|
|
301
|
+
* ownership lives in the callbacks it started rather than in this module. A
|
|
302
|
+
* straggler from the file before still carries the name it was written under,
|
|
303
|
+
* which the host cannot match to a test of the new file and files under that
|
|
304
|
+
* file instead. See ubugeeei-prod/uf#203.
|
|
305
|
+
*/
|
|
306
|
+
export function startFile(): void {
|
|
307
|
+
captured = 0;
|
|
308
|
+
stopped = false;
|
|
309
|
+
}
|
package/internal/registry.js
CHANGED
|
@@ -32,27 +32,27 @@ export type Modifier = "none" | "only" | "skip" | "todo";
|
|
|
32
32
|
|
|
33
33
|
/** One registered test case. */
|
|
34
34
|
export type Case = {|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
readonly kind: "test",
|
|
36
|
+
readonly name: string,
|
|
37
|
+
readonly body: Body | null,
|
|
38
|
+
readonly modifier: Modifier,
|
|
39
|
+
readonly timeoutMs: number | null,
|
|
40
|
+
readonly line: number,
|
|
41
|
+
readonly column: number,
|
|
42
42
|
|};
|
|
43
43
|
|
|
44
44
|
/** One `describe` and everything inside it. */
|
|
45
45
|
export type Suite = {|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
readonly kind: "suite",
|
|
47
|
+
readonly name: string,
|
|
48
|
+
readonly modifier: Modifier,
|
|
49
|
+
readonly children: Array<Suite | Case>,
|
|
50
|
+
readonly beforeAll: Array<Body>,
|
|
51
|
+
readonly afterAll: Array<Body>,
|
|
52
|
+
readonly beforeEach: Array<Body>,
|
|
53
|
+
readonly afterEach: Array<Body>,
|
|
54
|
+
readonly line: number,
|
|
55
|
+
readonly column: number,
|
|
56
56
|
|};
|
|
57
57
|
|
|
58
58
|
function suite(name: string, modifier: Modifier, line: number, column: number): Suite {
|
|
@@ -101,7 +101,7 @@ export function collected(): Suite {
|
|
|
101
101
|
* not in a shape we understand, the position is `0`, which every consumer
|
|
102
102
|
* treats as "unknown" rather than as line one.
|
|
103
103
|
*/
|
|
104
|
-
function callSite(): {|
|
|
104
|
+
function callSite(): {| readonly line: number, readonly column: number |} {
|
|
105
105
|
return firstUserSite(new Error("position").stack) ?? { line: 0, column: 0 };
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -118,7 +118,12 @@ function addSuite(name: string, body: Body, modifier: Modifier): void {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function addCase(
|
|
121
|
+
function addCase(
|
|
122
|
+
name: string,
|
|
123
|
+
body: Body | null,
|
|
124
|
+
modifier: Modifier,
|
|
125
|
+
timeoutMs: number | null,
|
|
126
|
+
): void {
|
|
122
127
|
const position = callSite();
|
|
123
128
|
current.children.push({
|
|
124
129
|
kind: "test",
|
|
@@ -132,7 +137,7 @@ function addCase(name: string, body: Body | null, modifier: Modifier, timeoutMs:
|
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
/** Options a single test may carry. */
|
|
135
|
-
export type TestOptions = {|
|
|
140
|
+
export type TestOptions = {| readonly timeout?: number |};
|
|
136
141
|
|
|
137
142
|
/**
|
|
138
143
|
* The `describe` API, and its modifiers.
|
|
@@ -155,13 +160,11 @@ function suiteApi(): $FlowFixMe {
|
|
|
155
160
|
api.todo = (name: string, body?: Body) => {
|
|
156
161
|
addSuite(name, body ?? (() => {}), "todo");
|
|
157
162
|
};
|
|
158
|
-
api.each =
|
|
159
|
-
(table
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
};
|
|
163
|
+
api.each = (table: $ReadOnlyArray<mixed>) => (name: string, body: (row: mixed) => mixed) => {
|
|
164
|
+
for (const row of table) {
|
|
165
|
+
addSuite(formatRow(name, row), () => body(row), "none");
|
|
166
|
+
}
|
|
167
|
+
};
|
|
165
168
|
return api;
|
|
166
169
|
}
|
|
167
170
|
|