@uniflowed/test 0.0.0-alpha.9 → 0.2.0
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/app-browser.js +6 -0
- package/app.js +175 -0
- package/browser-worker.js +334 -0
- package/browser.js +59 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +11 -1
- package/internal/axe.js +362 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +893 -0
- package/internal/browser/transport.js +2 -0
- package/internal/expect.js +506 -100
- package/internal/frames.js +178 -0
- package/internal/isolation.js +231 -0
- package/internal/namespace.js +22 -10
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +25 -7
- package/internal/registry.js +102 -17
- package/internal/run.js +159 -24
- package/internal/timers.js +2 -2
- package/package.json +41 -5
- package/worker.js +115 -10
package/internal/frames.js
CHANGED
|
@@ -195,3 +195,181 @@ export function userFrames(stack: string | null | void): string | null {
|
|
|
195
195
|
const frames = lines.slice(1).filter((frame) => !isInternalFrame(frame));
|
|
196
196
|
return frames.length === 0 ? head : [head, ...frames].join("\n");
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A V8 call site, as much of it as [`callerSite`] reads.
|
|
201
|
+
*
|
|
202
|
+
* Written out rather than imported: there is no library definition for V8's
|
|
203
|
+
* structured stack API, and these four methods are all it asks of one.
|
|
204
|
+
*/
|
|
205
|
+
type CallSite = interface {
|
|
206
|
+
getFileName(): ?string,
|
|
207
|
+
getLineNumber(): ?number,
|
|
208
|
+
getColumnNumber(): ?number,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** The piece of `node:module`'s source-map API [`callerSite`] needs. */
|
|
212
|
+
type SourceMapEntry = {|
|
|
213
|
+
readonly originalLine?: number,
|
|
214
|
+
readonly originalColumn?: number,
|
|
215
|
+
readonly originalSource?: string,
|
|
216
|
+
|};
|
|
217
|
+
type FindSourceMap = (
|
|
218
|
+
file: string,
|
|
219
|
+
) => ?interface { findEntry(line: number, column: number): SourceMapEntry };
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `node:module`'s `findSourceMap` when the host is Node with source maps on;
|
|
223
|
+
* `false` when the structured path must not be taken; `undefined` until asked.
|
|
224
|
+
*
|
|
225
|
+
* Asked once per process: the host does not change under a running worker.
|
|
226
|
+
*/
|
|
227
|
+
let nodeSourceMaps: FindSourceMap | false | void;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* How to map a generated position the way Node's own stack traces do, or
|
|
231
|
+
* `false` when this host's stacks cannot be reproduced from call sites.
|
|
232
|
+
*
|
|
233
|
+
* Node only. Bun and Deno implement the call-site API too, but whether their
|
|
234
|
+
* call sites carry the generated position or the mapped one is theirs to
|
|
235
|
+
* decide and has changed between releases, so they keep the string path,
|
|
236
|
+
* whose answer is by construction the one their `.stack` prints. A browser
|
|
237
|
+
* has no `process` at all.
|
|
238
|
+
*/
|
|
239
|
+
function sourceMapsForCallSites(): FindSourceMap | false {
|
|
240
|
+
if (nodeSourceMaps !== undefined) {
|
|
241
|
+
return nodeSourceMaps;
|
|
242
|
+
}
|
|
243
|
+
const host: $FlowFixMe = globalThis;
|
|
244
|
+
const process = host.process;
|
|
245
|
+
const isNode =
|
|
246
|
+
typeof process?.versions?.node === "string" &&
|
|
247
|
+
process.versions.bun == null &&
|
|
248
|
+
host.Deno == null &&
|
|
249
|
+
typeof process.getBuiltinModule === "function" &&
|
|
250
|
+
typeof Error.captureStackTrace === "function";
|
|
251
|
+
if (!isNode) {
|
|
252
|
+
nodeSourceMaps = false;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const findSourceMap = process.getBuiltinModule("node:module")?.findSourceMap;
|
|
256
|
+
nodeSourceMaps =
|
|
257
|
+
typeof findSourceMap === "function"
|
|
258
|
+
? // Only while Node applies source maps to its own stacks: with them off
|
|
259
|
+
// a `.stack` prints the generated position, and so must this.
|
|
260
|
+
(file) => (process.sourceMapsEnabled === true ? findSourceMap(file) : null)
|
|
261
|
+
: false;
|
|
262
|
+
return nodeSourceMaps;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The first position outside the runner on the stack of the call to `skip`,
|
|
267
|
+
* as `firstUserSite(new Error().stack)` would read it — or `undefined` when
|
|
268
|
+
* this host cannot answer that way, and the caller should build the string.
|
|
269
|
+
*
|
|
270
|
+
* # Why not simply read `.stack`
|
|
271
|
+
*
|
|
272
|
+
* Because it is the most expensive line in registering a test. `describe` and
|
|
273
|
+
* `it` ask where they were called from, once per case, and on Node with
|
|
274
|
+
* `--enable-source-maps` — which every `uf test` worker runs with — the string
|
|
275
|
+
* `.stack` is built by mapping *every* frame through its module's source map
|
|
276
|
+
* and printing each one, to read back one line and column from the first that
|
|
277
|
+
* is not the runner's. On a suite of 50 files and 1,000 cases that was about a
|
|
278
|
+
* sixth of a worker's CPU.
|
|
279
|
+
*
|
|
280
|
+
* V8 hands the same frames over unprinted to a `prepareStackTrace` installed
|
|
281
|
+
* for the one capture, and only the frame the answer comes from is mapped,
|
|
282
|
+
* with the lookup Node's printer itself uses — `findSourceMap(file)` then
|
|
283
|
+
* `findEntry(line - 1, column - 1)`, falling back to the generated position
|
|
284
|
+
* when there is no map or no entry — so the number is the one the string would
|
|
285
|
+
* have carried. `packages/test/registration-site.test.js` holds the two paths
|
|
286
|
+
* to that.
|
|
287
|
+
*/
|
|
288
|
+
export function callerSite(skip: (...args: $ReadOnlyArray<empty>) => mixed): Site | null | void {
|
|
289
|
+
const findSourceMap = sourceMapsForCallSites();
|
|
290
|
+
if (findSourceMap === false) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const errors: $FlowFixMe = Error;
|
|
294
|
+
const limit: mixed = errors.stackTraceLimit;
|
|
295
|
+
const full = typeof limit === "number" ? limit : 0;
|
|
296
|
+
// A few frames first. The caller of a registration is two or three frames
|
|
297
|
+
// above it — `it`, the modifier or `each` wrapper, `addCase` — and V8's cost
|
|
298
|
+
// is per frame it materialises, so the whole default ten is walked only for
|
|
299
|
+
// the rare caller that is deeper than that.
|
|
300
|
+
const shallow = Math.min(full, SHALLOW_FRAMES);
|
|
301
|
+
const first = readCallSites(skip, shallow);
|
|
302
|
+
if (first === undefined) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const found = firstUserCallSite(first, findSourceMap);
|
|
306
|
+
if (found != null || first.length < shallow || shallow === full) {
|
|
307
|
+
return found;
|
|
308
|
+
}
|
|
309
|
+
const again = readCallSites(skip, full);
|
|
310
|
+
return again === undefined ? undefined : firstUserCallSite(again, findSourceMap);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How many frames [`callerSite`] asks for before it asks for all of them. */
|
|
314
|
+
const SHALLOW_FRAMES = 4;
|
|
315
|
+
|
|
316
|
+
/** Hands V8's call sites back unprinted; one function, so none is made per capture. */
|
|
317
|
+
function unprinted(_error: mixed, sites: $ReadOnlyArray<CallSite>): $ReadOnlyArray<CallSite> {
|
|
318
|
+
return sites;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Up to `limit` call sites above `skip`, or `undefined` when the host does not
|
|
323
|
+
* hand them over.
|
|
324
|
+
*/
|
|
325
|
+
function readCallSites(
|
|
326
|
+
skip: (...args: $ReadOnlyArray<empty>) => mixed,
|
|
327
|
+
limit: number,
|
|
328
|
+
): $ReadOnlyArray<CallSite> | void {
|
|
329
|
+
const errors: $FlowFixMe = Error;
|
|
330
|
+
const prepare = errors.prepareStackTrace;
|
|
331
|
+
const before = errors.stackTraceLimit;
|
|
332
|
+
const holder: $FlowFixMe = {};
|
|
333
|
+
try {
|
|
334
|
+
errors.prepareStackTrace = unprinted;
|
|
335
|
+
errors.stackTraceLimit = limit;
|
|
336
|
+
errors.captureStackTrace(holder, skip);
|
|
337
|
+
// Read inside the `try`: V8 formats `stack` lazily, on first access, and
|
|
338
|
+
// with whatever `prepareStackTrace` is installed *then*. What comes back is
|
|
339
|
+
// what `unprinted` returned, which V8 does not type.
|
|
340
|
+
const sites: $FlowFixMe = holder.stack;
|
|
341
|
+
return Array.isArray(sites) ? sites : undefined;
|
|
342
|
+
} finally {
|
|
343
|
+
errors.prepareStackTrace = prepare;
|
|
344
|
+
errors.stackTraceLimit = before;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The first of `sites` outside the runner, mapped as Node maps a printed frame. */
|
|
349
|
+
function firstUserCallSite(
|
|
350
|
+
sites: $ReadOnlyArray<CallSite>,
|
|
351
|
+
findSourceMap: FindSourceMap,
|
|
352
|
+
): Site | null {
|
|
353
|
+
for (const site of sites) {
|
|
354
|
+
const file = site.getFileName();
|
|
355
|
+
if (file == null || isInternalFrame(file)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const line = site.getLineNumber();
|
|
359
|
+
const column = site.getColumnNumber();
|
|
360
|
+
if (line == null || column == null) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const entry = findSourceMap(file)?.findEntry(line - 1, column - 1);
|
|
364
|
+
if (
|
|
365
|
+
entry?.originalSource != null &&
|
|
366
|
+
entry.originalSource !== "" &&
|
|
367
|
+
entry.originalLine != null &&
|
|
368
|
+
entry.originalColumn != null
|
|
369
|
+
) {
|
|
370
|
+
return { line: entry.originalLine + 1, column: entry.originalColumn + 1 };
|
|
371
|
+
}
|
|
372
|
+
return { line, column };
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: what a file is allowed to leave behind.
|
|
4
|
+
//
|
|
5
|
+
// A worker serves many files out of one process, one at a time (`../worker.js`
|
|
6
|
+
// says why). Everything a file registers lives in this package and is cleared
|
|
7
|
+
// with it; everything a file *reaches around* the package to change belongs to
|
|
8
|
+
// the process, outlives the file, and is handed to whichever file the schedule
|
|
9
|
+
// puts next in that worker.
|
|
10
|
+
//
|
|
11
|
+
// That second list is what this module is. It has been discovered four times,
|
|
12
|
+
// and each time the same way — a suite that passed alone and failed beside
|
|
13
|
+
// another, naming the file that read the value rather than the file that wrote
|
|
14
|
+
// it:
|
|
15
|
+
//
|
|
16
|
+
// * ubugeeei-prod/uf#417, `uft.stubEnv("NODE_ENV", …)` still set for the
|
|
17
|
+
// next file;
|
|
18
|
+
// * ubugeeei-prod/uf#581, a fake clock still installed, so the next file's
|
|
19
|
+
// `setTimeout` — including the one each case is raced against — never
|
|
20
|
+
// fired and the file hung with nothing on screen;
|
|
21
|
+
// * ubugeeei-prod/uf#607, `document.body` still holding the markup a
|
|
22
|
+
// hydration test wrote into it, so the next file's "there is one image on
|
|
23
|
+
// the page" found six;
|
|
24
|
+
// * ubugeeei-prod/uf#944, the window around that body: a `matchMedia` a file
|
|
25
|
+
// removed, and the document's `FormData` left in place of Node's. That one is not on the list below, because this package does not
|
|
26
|
+
// make the window — `@uniflowed/react-testing` does, and registers how to
|
|
27
|
+
// put it back (see `registeredElsewhere`).
|
|
28
|
+
//
|
|
29
|
+
// Which files share a worker is decided by `.uf/test-timings.json`, so a leak
|
|
30
|
+
// makes the *result* of a suite depend on how the machine was loaded the last
|
|
31
|
+
// time it ran. That is the failure this module exists to end, and the reason
|
|
32
|
+
// it is a list in one place rather than three calls in `../worker.js`: the
|
|
33
|
+
// question "what else does a file share with the next one" now has somewhere
|
|
34
|
+
// to be answered, and a fourth answer is one entry rather than one more thing
|
|
35
|
+
// to remember.
|
|
36
|
+
//
|
|
37
|
+
// # What belongs here
|
|
38
|
+
//
|
|
39
|
+
// State that (a) the process shares, (b) a test can change from inside a file,
|
|
40
|
+
// and (c) nothing else puts back. Anything a file merely *reads* does not
|
|
41
|
+
// belong here, and neither does anything the registry already clears — a spy
|
|
42
|
+
// is not on this list because `reset()` is what a spy lives in.
|
|
43
|
+
//
|
|
44
|
+
// # When it runs
|
|
45
|
+
//
|
|
46
|
+
// Before the next file is imported, not after the previous one is run. A file
|
|
47
|
+
// that throws while loading is still a file that has run code, and it still
|
|
48
|
+
// hands the next one whatever that code changed; putting things back on the
|
|
49
|
+
// way *in* covers the load failure and the crash as well as the ordinary end.
|
|
50
|
+
// It also means the first file in a worker starts from the same state as the
|
|
51
|
+
// tenth.
|
|
52
|
+
|
|
53
|
+
import { reset } from "./registry.js";
|
|
54
|
+
import { resetModuleState } from "./modules.js";
|
|
55
|
+
import { unstubAllEnvs, unstubAllGlobals } from "./namespace.js";
|
|
56
|
+
// Renamed at the door, for two reasons that agree. It reads as the resets
|
|
57
|
+
// beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
|
|
58
|
+
// verb-first, and so is what this does to the clock.
|
|
59
|
+
import { restoreRealClock } from "./timers.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One piece of process-wide state a file can change, and how to put it back.
|
|
63
|
+
*
|
|
64
|
+
* `what` is written for a person reading this list rather than for any code:
|
|
65
|
+
* nothing branches on it, and it is here because a list of five bare function
|
|
66
|
+
* references is a list nobody can check against the paragraph above it.
|
|
67
|
+
*/
|
|
68
|
+
type Shared = {|
|
|
69
|
+
readonly what: string,
|
|
70
|
+
readonly restore: () => void,
|
|
71
|
+
|};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything a file shares with the file after it, in the order it goes back.
|
|
75
|
+
*
|
|
76
|
+
* The order matters in one place and is harmless everywhere else: the clock
|
|
77
|
+
* goes back before the module stand-ins do, because a stand-in's factory runs
|
|
78
|
+
* on the next import and a factory that schedules anything under a leaked fake
|
|
79
|
+
* clock would schedule it into a clock nobody is going to advance.
|
|
80
|
+
*/
|
|
81
|
+
const SHARED: $ReadOnlyArray<Shared> = [
|
|
82
|
+
{
|
|
83
|
+
what: "the tests, hooks and spies this package registered",
|
|
84
|
+
restore: reset,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// `process.env` belongs to the process. `uft.stubEnv("NODE_ENV",
|
|
88
|
+
// "production")` in one file is still set when the next one imports, and
|
|
89
|
+
// the file that fails is the one that read it.
|
|
90
|
+
what: "environment variables `uft.stubEnv` replaced",
|
|
91
|
+
restore: unstubAllEnvs,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
// `globalThis` likewise, and worse: a stubbed `fetch` makes the next file
|
|
95
|
+
// talk to a stand-in that does not know about it.
|
|
96
|
+
what: "globals `uft.stubGlobal` replaced",
|
|
97
|
+
restore: unstubAllGlobals,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
// Worse than a leaked value, and worse in a way that hides it. A leaked
|
|
101
|
+
// stub makes the next file read something wrong, which arrives as an
|
|
102
|
+
// assertion naming the value. A leaked clock makes the next file's
|
|
103
|
+
// `setTimeout` never fire — including the one `withTimeout` races each
|
|
104
|
+
// case against — so the file hangs with nothing on screen until `uf`'s own
|
|
105
|
+
// deadline kills the worker, and the report names the file that waited
|
|
106
|
+
// rather than the file that stopped time.
|
|
107
|
+
what: "the clock, whatever `uft.useFakeTimers` did to it",
|
|
108
|
+
restore: restoreRealClock,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// A worker serves many files out of one module registry, so this is the
|
|
112
|
+
// difference between "one file at a time" and "one file's mocks at a
|
|
113
|
+
// time".
|
|
114
|
+
what: "modules `uft.mock` stood in for",
|
|
115
|
+
restore: resetModuleState,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
what: "the document, if this process has one",
|
|
119
|
+
restore: restoreDocument,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Hand the next file a document nobody else has written to.
|
|
125
|
+
*
|
|
126
|
+
* The document is process-wide in a way that is easy to miss, because nothing
|
|
127
|
+
* in this package installs it: `@uniflowed/react-testing` puts one on the
|
|
128
|
+
* global object the first time a test renders and deliberately keeps it for
|
|
129
|
+
* the life of the process — replacing it would strand every React root already
|
|
130
|
+
* mounted in the old one. So one document serves every file a worker runs, and
|
|
131
|
+
* what a file leaves in it is what the next file queries.
|
|
132
|
+
*
|
|
133
|
+
* Its *contents* are put back rather than the document itself, and "back"
|
|
134
|
+
* means empty: a file is handed the body it would have had if it had installed
|
|
135
|
+
* the document itself. `cleanup()` already unmounts what `render` mounted, and
|
|
136
|
+
* that is not the leak — the leak is markup a test wrote into the body by
|
|
137
|
+
* hand, which a hydration test must do because hydration is React attaching to
|
|
138
|
+
* markup that is already there. `rsc-split.test.js` and `streaming.test.js`
|
|
139
|
+
* both `replaceChildren` into the body, and the file after them in that worker
|
|
140
|
+
* started with somebody else's page.
|
|
141
|
+
*
|
|
142
|
+
* Read through `globalThis` and guarded by `typeof`, because a worker running
|
|
143
|
+
* a suite that never renders has no `document` at all and must not pay for
|
|
144
|
+
* one — and because on a host that *is* a browser this is the page, whose body
|
|
145
|
+
* a run of `uf test` has no business emptying. It only ever clears a body that
|
|
146
|
+
* a test process is using as scratch space, which is every case this can
|
|
147
|
+
* reach: the shim's document, or a page the project chose to run its own tests
|
|
148
|
+
* in.
|
|
149
|
+
*/
|
|
150
|
+
function restoreDocument(): void {
|
|
151
|
+
if (typeof globalThis.document === "undefined") {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const body = globalThis.document.body;
|
|
155
|
+
if (body == null) {
|
|
156
|
+
// A document a parser produced need not have one, and a document with no
|
|
157
|
+
// body is a document with nothing to put back.
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
body.replaceChildren();
|
|
161
|
+
// The attributes too, and not for tidiness: `<body class="dark">` is how a
|
|
162
|
+
// theme test says what it is testing, and a file that leaves one behind
|
|
163
|
+
// makes the next file's "the page is in light mode" false for a reason it
|
|
164
|
+
// cannot see. Read into an array first — removing an attribute while
|
|
165
|
+
// iterating a live list is the loop that skips every other entry.
|
|
166
|
+
for (const name of [...body.getAttributeNames()]) {
|
|
167
|
+
body.removeAttribute(name);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where other packages register process-wide state of their own.
|
|
173
|
+
*
|
|
174
|
+
* A package this one does not depend on can still install something a file
|
|
175
|
+
* changes and the next file reads: `@uniflowed/react-testing` installs a window
|
|
176
|
+
* on the first render and keeps it for the process. Only that package knows
|
|
177
|
+
* what the window looked like when it made it, so it registers how to put it
|
|
178
|
+
* back — a name for the message below, and a function — in a `Map` under this
|
|
179
|
+
* symbol, and every entry runs after the list above.
|
|
180
|
+
*
|
|
181
|
+
* A symbol from the global registry rather than an export, so the package that
|
|
182
|
+
* registers needs no import of this one: under another runner the entry is
|
|
183
|
+
* never read and costs nothing.
|
|
184
|
+
*/
|
|
185
|
+
const SHARED_STATE: symbol = Symbol.for("@uniflowed/test/shared-state");
|
|
186
|
+
|
|
187
|
+
/** Every entry another package registered, as this module's own entries are shaped. */
|
|
188
|
+
function registeredElsewhere(): $ReadOnlyArray<Shared> {
|
|
189
|
+
const registry: mixed = Reflect.get(globalThis, SHARED_STATE);
|
|
190
|
+
if (!(registry instanceof Map)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const entries: Array<Shared> = [];
|
|
194
|
+
for (const [what, restore] of registry) {
|
|
195
|
+
if (typeof what === "string" && typeof restore === "function") {
|
|
196
|
+
entries.push({ what, restore: () => void restore() });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return entries;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Put back everything the file that just ran may have changed.
|
|
204
|
+
*
|
|
205
|
+
* Called by `../worker.js` before it imports the next file. Nothing here
|
|
206
|
+
* reports a file for having changed any of this: a file is *allowed* to — that
|
|
207
|
+
* is what the `uft` namespace is for — and the contract is that the change
|
|
208
|
+
* does not outlive the file, not that it never happened.
|
|
209
|
+
*
|
|
210
|
+
* Every entry is attempted even after one has thrown, and the first failure is
|
|
211
|
+
* raised afterwards under the name of what it could not put back. Stopping at
|
|
212
|
+
* the first would leave the four entries behind it un-restored, which is the
|
|
213
|
+
* defect this module exists to prevent arriving by a new route — and a bare
|
|
214
|
+
* throw from one of these used to say only that something in the runner failed
|
|
215
|
+
* between two files.
|
|
216
|
+
*/
|
|
217
|
+
export function restoreSharedState(): void {
|
|
218
|
+
let failure: { readonly what: string, readonly thrown: mixed } | null = null;
|
|
219
|
+
for (const shared of [...SHARED, ...registeredElsewhere()]) {
|
|
220
|
+
try {
|
|
221
|
+
shared.restore();
|
|
222
|
+
} catch (thrown) {
|
|
223
|
+
failure ??= { what: shared.what, thrown };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (failure != null) {
|
|
227
|
+
const cause = failure.thrown;
|
|
228
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
229
|
+
throw new Error(`@uniflowed/test could not put back ${failure.what}: ${message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
package/internal/namespace.js
CHANGED
|
@@ -50,8 +50,11 @@ const stubbedGlobals: Map<string, { readonly owned: boolean, readonly value: mix
|
|
|
50
50
|
/**
|
|
51
51
|
* Read the process environment, whichever host this is.
|
|
52
52
|
*
|
|
53
|
-
* Node
|
|
54
|
-
*
|
|
53
|
+
* Node and Bun expose `process` as a global; **Deno does not**, and has the
|
|
54
|
+
* same object under `node:process`. `../worker.js` installs it on the global
|
|
55
|
+
* before anything here runs, which is what keeps this one code path across the
|
|
56
|
+
* three — and is why the `null` branch below is still reachable, for a host
|
|
57
|
+
* that is neither.
|
|
55
58
|
*/
|
|
56
59
|
function environment(): { [string]: string } | null {
|
|
57
60
|
const host = globalThis as $FlowFixMe;
|
|
@@ -59,10 +62,15 @@ function environment(): { [string]: string } | null {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
|
62
|
-
* Replace an environment variable for the rest of the
|
|
65
|
+
* Replace an environment variable for the rest of the file.
|
|
63
66
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* The file, and not the test: `process.env` belongs to the process, so a stub
|
|
68
|
+
* stands until something puts it back. `./worker.js` does that between files,
|
|
69
|
+
* beside the spy registry it clears for the same reason — a worker serves many
|
|
70
|
+
* files, and a stub that outlived its file would be a test that passes because
|
|
71
|
+
* of another one, in a suite where which files share a worker is decided by a
|
|
72
|
+
* timings file. A case that wants a narrower scope calls `unstubAllEnvs` in an
|
|
73
|
+
* `afterEach`, which is also what makes the scope visible to a reader.
|
|
66
74
|
*/
|
|
67
75
|
export function stubEnv(name: string, value: string | void): void {
|
|
68
76
|
const env = environment();
|
|
@@ -97,7 +105,11 @@ export function unstubAllEnvs(): void {
|
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
/**
|
|
100
|
-
* Replace a global for the rest of the
|
|
108
|
+
* Replace a global for the rest of the file.
|
|
109
|
+
*
|
|
110
|
+
* Undone by `unstubAllGlobals`, which `./worker.js` calls between files, for
|
|
111
|
+
* the reason [`stubEnv`] above gives: `globalThis` outlives every file that
|
|
112
|
+
* writes to it.
|
|
101
113
|
*
|
|
102
114
|
* Whether the global was the object's own property is recorded, because putting
|
|
103
115
|
* back an inherited one by assignment would leave a copy that shadows whatever
|
|
@@ -214,8 +226,8 @@ export type Uft = {
|
|
|
214
226
|
readonly waitFor: typeof waitFor,
|
|
215
227
|
readonly waitUntil: typeof waitUntil,
|
|
216
228
|
|
|
217
|
-
readonly useFakeTimers: typeof timers.
|
|
218
|
-
readonly useRealTimers: typeof timers.
|
|
229
|
+
readonly useFakeTimers: typeof timers.installFakeClock,
|
|
230
|
+
readonly useRealTimers: typeof timers.restoreRealClock,
|
|
219
231
|
readonly isFakeTimers: typeof timers.isFaked,
|
|
220
232
|
readonly advanceTimersByTime: typeof timers.advanceTimersByTime,
|
|
221
233
|
readonly advanceTimersByTimeAsync: typeof timers.advanceTimersByTimeAsync,
|
|
@@ -261,8 +273,8 @@ export const uft: Uft = Object.freeze({
|
|
|
261
273
|
|
|
262
274
|
// The clock a test controls. A test about "after five minutes the session
|
|
263
275
|
// expires" should not take five minutes.
|
|
264
|
-
useFakeTimers: timers.
|
|
265
|
-
useRealTimers: timers.
|
|
276
|
+
useFakeTimers: timers.installFakeClock,
|
|
277
|
+
useRealTimers: timers.restoreRealClock,
|
|
266
278
|
isFakeTimers: timers.isFaked,
|
|
267
279
|
advanceTimersByTime: timers.advanceTimersByTime,
|
|
268
280
|
advanceTimersByTimeAsync: timers.advanceTimersByTimeAsync,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
// The facade returns the outer uf worker's registry, not another test runner.
|
|
3
|
+
const environment: $FlowFixMe = globalThis;
|
|
4
|
+
const api = environment.__UF_NATIVE_TEST_API__;
|
|
5
|
+
export const test = api.test;
|
|
6
|
+
export const it = api.it;
|
|
7
|
+
export const describe = api.describe;
|
|
8
|
+
export const beforeAll = api.beforeAll;
|
|
9
|
+
export const afterAll = api.afterAll;
|
|
10
|
+
export const beforeEach = api.beforeEach;
|
|
11
|
+
export const afterEach = api.afterEach;
|
|
12
|
+
export const expect = api.expect;
|
|
13
|
+
export const fn = api.fn;
|
|
14
|
+
export const spyOn = api.spyOn;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
// Jest's module environment executes RN's own mocks. uf still owns discovery,
|
|
3
|
+
// scheduling, hooks, timeouts, isolation and the result protocol.
|
|
4
|
+
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import * as tests from "../index.js";
|
|
9
|
+
|
|
10
|
+
// Optional, application-owned environment modules cross one dynamic boundary.
|
|
11
|
+
type EnvironmentModule = $FlowFixMe;
|
|
12
|
+
|
|
13
|
+
export async function loadNativeFile(file: string): Promise<() => Promise<void>> {
|
|
14
|
+
const rootDir = process.cwd();
|
|
15
|
+
const require = createRequire(path.join(rootDir, "package.json"));
|
|
16
|
+
const load = (name: string): EnvironmentModule => {
|
|
17
|
+
try {
|
|
18
|
+
return require(name);
|
|
19
|
+
} catch (cause) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`uf test (react-native): install ${name} in the application; the native environment could not load it`,
|
|
22
|
+
{ cause },
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const Runtime = load("jest-runtime").default;
|
|
27
|
+
const { readConfig } = load("jest-config");
|
|
28
|
+
const { createScriptTransformer } = load("@jest/transform");
|
|
29
|
+
const { jestExpect } = load("@jest/expect");
|
|
30
|
+
const { projectConfig: config, globalConfig } = await readConfig(
|
|
31
|
+
{
|
|
32
|
+
config: JSON.stringify({
|
|
33
|
+
rootDir,
|
|
34
|
+
preset: "@react-native/jest-preset",
|
|
35
|
+
cacheDirectory: path.join(rootDir, ".uf", "native-test-cache"),
|
|
36
|
+
moduleNameMapper: {
|
|
37
|
+
"^@uniflowed/test$": fileURLToPath(new URL("./native-globals.js", import.meta.url).href),
|
|
38
|
+
},
|
|
39
|
+
transform: {
|
|
40
|
+
"^.+\\.[jt]sx?$": require.resolve("@uniflowed/react-native/test-transformer.cjs"),
|
|
41
|
+
},
|
|
42
|
+
transformIgnorePatterns: [
|
|
43
|
+
"node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|@uniflowed|react-native-screens|react-native-safe-area-context)/)",
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
rootDir,
|
|
48
|
+
);
|
|
49
|
+
const context = await Runtime.createContext(config, {
|
|
50
|
+
console,
|
|
51
|
+
maxWorkers: 1,
|
|
52
|
+
watch: false,
|
|
53
|
+
watchman: false,
|
|
54
|
+
});
|
|
55
|
+
const Environment = load(config.testEnvironment);
|
|
56
|
+
const environment = new (Environment.TestEnvironment ?? Environment)(
|
|
57
|
+
{ projectConfig: config, globalConfig },
|
|
58
|
+
{ console, testPath: file, docblockPragmas: {} },
|
|
59
|
+
);
|
|
60
|
+
await environment.setup();
|
|
61
|
+
let runtime: EnvironmentModule = null;
|
|
62
|
+
let closed = false;
|
|
63
|
+
const close = async () => {
|
|
64
|
+
if (closed) return;
|
|
65
|
+
closed = true;
|
|
66
|
+
try {
|
|
67
|
+
runtime?.leaveTestCode();
|
|
68
|
+
runtime?.teardown();
|
|
69
|
+
} finally {
|
|
70
|
+
await environment.teardown();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const transformer = await createScriptTransformer(config, new Map());
|
|
75
|
+
runtime = new Runtime(
|
|
76
|
+
config,
|
|
77
|
+
environment,
|
|
78
|
+
context.resolver,
|
|
79
|
+
transformer,
|
|
80
|
+
new Map(),
|
|
81
|
+
{ collectCoverage: false, collectCoverageFrom: [], coverageProvider: "v8" },
|
|
82
|
+
file,
|
|
83
|
+
globalConfig,
|
|
84
|
+
);
|
|
85
|
+
const api = { ...tests, expect: jestExpect };
|
|
86
|
+
for (const [key, value] of Object.entries(api)) environment.global[key] = value;
|
|
87
|
+
environment.global.__UF_NATIVE_TEST_API__ = api;
|
|
88
|
+
runtime.setGlobalsForRuntime(api);
|
|
89
|
+
runtime.enterTestCode();
|
|
90
|
+
for (const setup of config.setupFiles) runtime.requireModule(setup);
|
|
91
|
+
runtime.requireModule(file);
|
|
92
|
+
return close;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
await close();
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/internal/output.js
CHANGED
|
@@ -241,6 +241,19 @@ function writer(
|
|
|
241
241
|
* Installed once, for the life of the worker: a worker runs many files, and
|
|
242
242
|
* restoring the real methods between them would leave a window in which a
|
|
243
243
|
* straggling `setTimeout` from the previous file writes into the protocol.
|
|
244
|
+
*
|
|
245
|
+
* # A page has no stream to take
|
|
246
|
+
*
|
|
247
|
+
* `uf test --browser` runs this same capture inside a page
|
|
248
|
+
* (`./browser/page.js`), and there the whole premise of the returned value is
|
|
249
|
+
* absent: there is no `process.stdout`, so there is nothing for a test to
|
|
250
|
+
* write into by accident, and the protocol's channel is a separate HTTP
|
|
251
|
+
* request rather than a stream anything else can reach. So the two `write`
|
|
252
|
+
* methods are only replaced when there are two `write` methods, and the
|
|
253
|
+
* "raw stream" handed back is a no-op nobody has a use for.
|
|
254
|
+
*
|
|
255
|
+
* Deliberately not a `typeof process` check at each use: the question is asked
|
|
256
|
+
* once, here, because the answer cannot change under a running host.
|
|
244
257
|
*/
|
|
245
258
|
export function install(to: OutputSink): (chunk: string) => void {
|
|
246
259
|
const global = host();
|
|
@@ -248,11 +261,14 @@ export function install(to: OutputSink): (chunk: string) => void {
|
|
|
248
261
|
if (already != null) {
|
|
249
262
|
return already;
|
|
250
263
|
}
|
|
251
|
-
const stdout = global.process
|
|
252
|
-
const real = stdout
|
|
253
|
-
const protocol =
|
|
254
|
-
real
|
|
255
|
-
|
|
264
|
+
const stdout = global.process?.stdout;
|
|
265
|
+
const real = stdout?.write;
|
|
266
|
+
const protocol =
|
|
267
|
+
real == null
|
|
268
|
+
? (_chunk: string) => {}
|
|
269
|
+
: (chunk: string) => {
|
|
270
|
+
real.call(stdout, chunk);
|
|
271
|
+
};
|
|
256
272
|
raw = protocol;
|
|
257
273
|
sink = to;
|
|
258
274
|
for (const method of Object.keys(CONSOLE_STREAMS)) {
|
|
@@ -271,8 +287,10 @@ export function install(to: OutputSink): (chunk: string) => void {
|
|
|
271
287
|
capture("stderr", `${userFrames(error.stack) ?? `Trace: ${error.message}`}\n`);
|
|
272
288
|
};
|
|
273
289
|
|
|
274
|
-
global.process
|
|
275
|
-
|
|
290
|
+
if (global.process?.stdout != null) {
|
|
291
|
+
global.process.stdout.write = writer("stdout");
|
|
292
|
+
global.process.stderr.write = writer("stderr");
|
|
293
|
+
}
|
|
276
294
|
return protocol;
|
|
277
295
|
}
|
|
278
296
|
|