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

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,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
+ }
@@ -21,25 +21,58 @@
21
21
  // # Why this is its own module
22
22
  //
23
23
  // Two callers need it and neither owns it: `worker.js` installs the capture at
24
- // start-up, and `run.js` says which case is running so a chunk can be named.
25
- // "Who printed this" is also state with a lifetime of its own — set around a
26
- // case's body and hooks, cleared between them — exactly like the snapshot key
27
- // in `snapshot.js`, and for the same reason it lives beside the thing it
28
- // describes rather than inside either caller.
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
29
  //
30
30
  // # What "the test that printed it" means
31
31
  //
32
- // The name a chunk carries is the case the *worker* is running when the chunk
33
- // arrives, which is not the same as the case whose code produced it. A
34
- // `setTimeout` a test leaves behind prints while the next case is running and
35
- // is filed under that one; a chunk from no case at all is filed under the
36
- // file.
32
+ // The case whose *asynchronous context* the write happened in, which is the
33
+ // case whose code produced it.
37
34
  //
38
- // Getting this exactly right needs the printing to be tied to the asynchronous
39
- // context the case ran in — `AsyncLocalStorage` and everything under it — and
40
- // that is a bigger change than this module, because it has to reach the
41
- // scheduler that runs the cases. It is written down here rather than left to
42
- // be discovered from a confusing report. See ubugeeei-prod/uf#207.
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.
43
76
 
44
77
  // # Bounds
45
78
  //
@@ -49,6 +82,7 @@
49
82
  // nothing after it is kept. The budget starts over for each file, so a chatty
50
83
  // file does not silence the next one in the same worker.
51
84
 
85
+ import { AsyncLocalStorage } from "node:async_hooks";
52
86
  import { format, inspect } from "node:util";
53
87
 
54
88
  import { userFrames } from "./frames.js";
@@ -95,9 +129,16 @@ const DECODER = new TextDecoder();
95
129
  /** What a stream's `write` calls when it has taken the chunk. */
96
130
  type WriteCallback = () => mixed;
97
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
+
98
140
  let sink: OutputSink | null = null;
99
141
  let raw: ((chunk: string) => void) | null = null;
100
- let current: string | null = null;
101
142
  let captured = 0;
102
143
  let stopped = false;
103
144
 
@@ -159,7 +200,7 @@ function capture(stream: OutputStream, text: string): void {
159
200
  stopped = true;
160
201
  }
161
202
  captured += kept.length;
162
- to({ stream, test: current, text: kept });
203
+ to({ stream, test: owner.getStore() ?? null, text: kept });
163
204
  }
164
205
 
165
206
  /** A stand-in for `process.stdout.write` / `process.stderr.write`. */
@@ -200,6 +241,19 @@ function writer(
200
241
  * Installed once, for the life of the worker: a worker runs many files, and
201
242
  * restoring the real methods between them would leave a window in which a
202
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.
203
257
  */
204
258
  export function install(to: OutputSink): (chunk: string) => void {
205
259
  const global = host();
@@ -207,11 +261,14 @@ export function install(to: OutputSink): (chunk: string) => void {
207
261
  if (already != null) {
208
262
  return already;
209
263
  }
210
- const stdout = global.process.stdout;
211
- const real = stdout.write;
212
- const protocol = (chunk: string) => {
213
- real.call(stdout, chunk);
214
- };
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
+ };
215
272
  raw = protocol;
216
273
  sink = to;
217
274
  for (const method of Object.keys(CONSOLE_STREAMS)) {
@@ -230,31 +287,41 @@ export function install(to: OutputSink): (chunk: string) => void {
230
287
  capture("stderr", `${userFrames(error.stack) ?? `Trace: ${error.message}`}\n`);
231
288
  };
232
289
 
233
- global.process.stdout.write = writer("stdout");
234
- global.process.stderr.write = writer("stderr");
290
+ if (global.process?.stdout != null) {
291
+ global.process.stdout.write = writer("stdout");
292
+ global.process.stderr.write = writer("stderr");
293
+ }
235
294
  return protocol;
236
295
  }
237
296
 
238
297
  /**
239
- * Say which case is running, so what it prints can be named.
298
+ * Run `body` as `name`, so what it prints — and what it leaves behind to print
299
+ * later — is filed under that case.
300
+ *
301
+ * The runner wraps one case's `beforeEach`, body and `afterEach` in a single
302
+ * call, because those are the one case's work. Whatever `body` returns is
303
+ * returned unchanged, so an `await` on this is an `await` on the case.
240
304
  *
241
- * The runner calls this around a case's body and hooks and clears it between
242
- * them: output written while the module is being imported, from a `beforeAll`,
243
- * or after the last case finished belongs to the file, not to whichever case
244
- * happened to run last.
305
+ * Nothing here needs an "and now nothing is running" counterpart. Output from
306
+ * an import, a `beforeAll` or a case that has already been reported was never
307
+ * inside this call, so it has no owner and is the file's — which is the
308
+ * property the previous module-level variable had to be reset to keep, and
309
+ * kept only for as long as nothing straggled.
245
310
  */
246
- export function enterTest(name: string): void {
247
- current = name;
311
+ export function runInTest<T>(name: string, body: () => T): T {
312
+ return owner.run(name, body);
248
313
  }
249
314
 
250
- /** Say that no case is running. */
251
- export function exitTest(): void {
252
- current = null;
253
- }
254
-
255
- /** Start one file's output budget over, with no case running. */
315
+ /**
316
+ * Start one file's output budget over.
317
+ *
318
+ * Only the budget: there is no current case to clear, because a case's
319
+ * ownership lives in the callbacks it started rather than in this module. A
320
+ * straggler from the file before still carries the name it was written under,
321
+ * which the host cannot match to a test of the new file and files under that
322
+ * file instead. See ubugeeei-prod/uf#203.
323
+ */
256
324
  export function startFile(): void {
257
325
  captured = 0;
258
326
  stopped = false;
259
- current = null;
260
327
  }
@@ -30,13 +30,29 @@ export type Body = () => mixed | Promise<mixed>;
30
30
  /** The suffix written on a registration call. */
31
31
  export type Modifier = "none" | "only" | "skip" | "todo";
32
32
 
33
- /** One registered test case. */
33
+ /**
34
+ * How a benchmark runs under `uf test --bench`.
35
+ *
36
+ * `warmup` calls are made and their times thrown away, then `iterations` calls
37
+ * are each timed. `timeout` is the budget for one call, in milliseconds, and
38
+ * the benchmark as a whole is held to that budget once per call.
39
+ */
40
+ export type BenchOptions = {|
41
+ readonly warmup?: number,
42
+ readonly iterations?: number,
43
+ readonly timeout?: number,
44
+ |};
45
+
46
+ /** One registered test case, or one benchmark. */
34
47
  export type Case = {|
35
- readonly kind: "test",
48
+ readonly kind: "test" | "bench",
36
49
  readonly name: string,
37
50
  readonly body: Body | null,
38
51
  readonly modifier: Modifier,
52
+ readonly skipReason: string | null,
39
53
  readonly timeoutMs: number | null,
54
+ /** How to run it, for a benchmark; `null` for a test. */
55
+ readonly bench: BenchOptions | null,
40
56
  readonly line: number,
41
57
  readonly column: number,
42
58
  |};
@@ -123,14 +139,18 @@ function addCase(
123
139
  body: Body | null,
124
140
  modifier: Modifier,
125
141
  timeoutMs: number | null,
142
+ skipReason: string | null = null,
143
+ bench: BenchOptions | null = null,
126
144
  ): void {
127
145
  const position = callSite();
128
146
  current.children.push({
129
- kind: "test",
147
+ kind: bench == null ? "test" : "bench",
130
148
  name,
131
149
  body,
132
150
  modifier,
151
+ skipReason,
133
152
  timeoutMs,
153
+ bench,
134
154
  line: position.line,
135
155
  column: position.column,
136
156
  });
@@ -179,6 +199,9 @@ function caseApi(): $FlowFixMe {
179
199
  api.skip = (name: string, body?: Body) => {
180
200
  addCase(name, body ?? null, "skip", null);
181
201
  };
202
+ api.skipBecause = (name: string, reason: string, body?: Body) => {
203
+ addCase(name, body ?? null, "skip", null, reason);
204
+ };
182
205
  api.todo = (name: string, body?: Body) => {
183
206
  addCase(name, body ?? null, "todo", null);
184
207
  };
@@ -211,6 +234,34 @@ export const it: $FlowFixMe = caseApi();
211
234
  /** `test` is `it`, for people who write it that way. */
212
235
  export const test: $FlowFixMe = it;
213
236
 
237
+ /** The `bench` API, and its modifiers. See [`suiteApi`] for the shape. */
238
+ function benchApi(): $FlowFixMe {
239
+ const api: $FlowFixMe = (name: string, body: Body, options?: BenchOptions) => {
240
+ addCase(name, body, "none", options?.timeout ?? null, null, options ?? {});
241
+ };
242
+ api.only = (name: string, body: Body, options?: BenchOptions) => {
243
+ addCase(name, body, "only", options?.timeout ?? null, null, options ?? {});
244
+ };
245
+ api.skip = (name: string, body?: Body) => {
246
+ addCase(name, body ?? null, "skip", null, null, {});
247
+ };
248
+ api.todo = (name: string) => {
249
+ addCase(name, null, "todo", null, null, {});
250
+ };
251
+ return api;
252
+ }
253
+
254
+ /**
255
+ * Register one benchmark.
256
+ *
257
+ * `uf test` reports a benchmark as skipped, so a suite does not pay for timing
258
+ * one, and `uf test --bench` runs the benchmarks in place of the tests and
259
+ * reports how long each call took. `bench.only`, `bench.skip` and `bench.todo`
260
+ * do what `it`'s do. See [`BenchOptions`] for `warmup`, `iterations` and
261
+ * `timeout`.
262
+ */
263
+ export const bench: $FlowFixMe = benchApi();
264
+
214
265
  /**
215
266
  * Substitute a row into a name, the way every runner spells it: `%s` for the
216
267
  * value, `%j` for its JSON.