@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.
package/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
16
16
  export type { Outcome, Result, RunOptions } from "./internal/run.js";
17
17
  export type { Site } from "./internal/frames.js";
18
- export type { SpyCall } from "./internal/expect.js";
18
+ export type { SpyCall, SpyResult } from "./internal/spy.js";
19
19
  export type { Strictness } from "./internal/equality.js";
20
20
 
21
21
  export {
@@ -28,7 +28,24 @@ export {
28
28
  test,
29
29
  } from "./internal/registry.js";
30
30
 
31
- export { AssertionError, expect, fn } from "./internal/expect.js";
31
+ export { AssertionError, expect } from "./internal/expect.js";
32
+
33
+ export { fn, spyOn } from "./internal/spy.js";
34
+
35
+ /**
36
+ * Spies, stubs, a controllable clock, and waiting — under uf's own name.
37
+ *
38
+ * The same operations Vitest groups under `vi`, so a suite being ported keeps
39
+ * its shape; the name is uf's, because borrowing another tool's brand for it
40
+ * would be claiming something uf has not earned.
41
+ *
42
+ * A namespace rather than loose named exports, because several of these names
43
+ * are generic enough to collide: `@uniflowed/testing` re-exports both this
44
+ * package and `@uniflowed/react-testing`, and both have a `waitFor`.
45
+ */
46
+ export { UnsupportedError, uft } from "./internal/namespace.js";
47
+
48
+ export { RunawayTimersError } from "./internal/timers.js";
32
49
 
33
50
  export { DEFAULT_TIMEOUT_MS, NAME_SEPARATOR } from "./internal/run.js";
34
51
 
@@ -0,0 +1,184 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: `expect.any`, `expect.objectContaining`, and
4
+ // the rest of the matchers that stand in for a value instead of being one.
5
+ //
6
+ // They exist because most assertions are about the parts of a value a test
7
+ // controls, and equality is about all of it. `expect(user).toEqual({ id:
8
+ // expect.any(String), name: "uf" })` says what the test means; spelling out the
9
+ // id would either be a lie or a second source of truth.
10
+ //
11
+ // The mechanism is one method. A matcher is an object carrying
12
+ // `asymmetricMatch(received)`, and `equals` asks any value it meets whether it
13
+ // is one — so a matcher nested six levels down inside an expected object works
14
+ // for the same reason a top-level one does, with no special case anywhere.
15
+ // Vitest and Jest use the same protocol, which is why a matcher from either
16
+ // works here.
17
+
18
+ // `equality` and this module are mutually recursive, and that is the domain:
19
+ // comparing two values has to recognise a matcher, and a matcher has to compare
20
+ // recursively. ESM handles a cycle between hoisted function declarations — by
21
+ // the time any matcher runs, both modules have finished evaluating — and the
22
+ // alternative was wiring the comparison in at load, which is an import-time side
23
+ // effect and the one thing every shipped module here is forbidden.
24
+ import { equals } from "./equality.js";
25
+
26
+ /** The brand every matcher carries, so `equals` can recognise one. */
27
+ const ASYMMETRIC = "$$uf.asymmetricMatch";
28
+
29
+ /** A stand-in for a value, rather than a value. */
30
+ export type AsymmetricMatcher = {
31
+ readonly [typeof ASYMMETRIC]: true,
32
+ readonly asymmetricMatch: (received: mixed) => boolean,
33
+ readonly toString: () => string,
34
+ };
35
+
36
+ /**
37
+ * Whether `value` is a matcher rather than something to compare against.
38
+ *
39
+ * Duck-typed on `asymmetricMatch` as well as uf's own brand, so a matcher built
40
+ * by Jest or Vitest — or by a project's own helper — is recognised too. The
41
+ * protocol is the interface; the brand is only a fast path.
42
+ */
43
+ export function isAsymmetric(value: mixed): boolean {
44
+ if (value == null || typeof value !== "object") {
45
+ return false;
46
+ }
47
+ const candidate = value as $FlowFixMe;
48
+ return candidate[ASYMMETRIC] === true || typeof candidate.asymmetricMatch === "function";
49
+ }
50
+
51
+ /** Ask a matcher whether `received` satisfies it. */
52
+ export function matchesAsymmetric(matcher: mixed, received: mixed): boolean {
53
+ return (matcher as $FlowFixMe).asymmetricMatch(received) === true;
54
+ }
55
+
56
+ /** Build a matcher from a predicate and how it describes itself. */
57
+ function matcher(label: string, predicate: (received: mixed) => boolean): AsymmetricMatcher {
58
+ return {
59
+ [ASYMMETRIC]: true,
60
+ asymmetricMatch: predicate,
61
+ toString: () => label,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Anything constructed by `constructor`, or any value of a primitive's type.
67
+ *
68
+ * `expect.any(String)` accepts `"uf"` as well as `new String("uf")`, because a
69
+ * string literal is not an instance of anything and a test that wrote
70
+ * `expect.any(String)` meant the type rather than the wrapper. The same for
71
+ * `Number`, `Boolean`, `BigInt`, `Symbol` and `Function`.
72
+ */
73
+ export function any(constructor: mixed): AsymmetricMatcher {
74
+ const name = (constructor as $FlowFixMe)?.name ?? String(constructor);
75
+ return matcher(`Any<${name}>`, (received) => {
76
+ switch (constructor) {
77
+ case String:
78
+ return typeof received === "string" || received instanceof String;
79
+ case Number:
80
+ return typeof received === "number" || received instanceof Number;
81
+ case Boolean:
82
+ return typeof received === "boolean" || received instanceof Boolean;
83
+ case BigInt:
84
+ return typeof received === "bigint";
85
+ case Symbol:
86
+ return typeof received === "symbol";
87
+ case Function:
88
+ return typeof received === "function";
89
+ case Object:
90
+ // `Object` means "any non-null object", which is what a test asking for
91
+ // one means — not "has Object.prototype in its chain", which a
92
+ // null-prototype object would fail and a test would find baffling.
93
+ return received != null && (typeof received === "object" || typeof received === "function");
94
+ default:
95
+ return typeof constructor === "function" && received instanceof constructor;
96
+ }
97
+ });
98
+ }
99
+
100
+ /** Anything at all except `null` and `undefined`. */
101
+ export function anything(): AsymmetricMatcher {
102
+ return matcher("Anything", (received) => received != null);
103
+ }
104
+
105
+ /**
106
+ * An object with at least these properties, compared with `equals`.
107
+ *
108
+ * The comparison is recursive, so a matcher nested inside `expected` works.
109
+ */
110
+ export function objectContaining(expected: interface {}): AsymmetricMatcher {
111
+ return matcher(`ObjectContaining(${describe(expected)})`, (received) => {
112
+ if (received == null || typeof received !== "object") {
113
+ return false;
114
+ }
115
+ const target = received as $FlowFixMe;
116
+ const source = expected as $FlowFixMe;
117
+ for (const key of Object.keys(source)) {
118
+ if (!(key in target) || !equals(target[key], source[key])) {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
123
+ });
124
+ }
125
+
126
+ /** An array holding at least these elements, in any order. */
127
+ export function arrayContaining(expected: $ReadOnlyArray<mixed>): AsymmetricMatcher {
128
+ return matcher(`ArrayContaining(${describe(expected)})`, (received) => {
129
+ if (!Array.isArray(received)) {
130
+ return false;
131
+ }
132
+ return expected.every((wanted) => received.some((item) => equals(item, wanted)));
133
+ });
134
+ }
135
+
136
+ /** A string containing `substring`. */
137
+ export function stringContaining(substring: string): AsymmetricMatcher {
138
+ return matcher(`StringContaining(${JSON.stringify(substring)})`, (received) => {
139
+ return typeof received === "string" && received.includes(substring);
140
+ });
141
+ }
142
+
143
+ /** A string the pattern matches. */
144
+ export function stringMatching(pattern: string | RegExp): AsymmetricMatcher {
145
+ return matcher(`StringMatching(${String(pattern)})`, (received) => {
146
+ if (typeof received !== "string") {
147
+ return false;
148
+ }
149
+ return typeof pattern === "string" ? received.includes(pattern) : pattern.test(received);
150
+ });
151
+ }
152
+
153
+ /** Default digits of precision for `closeTo`, matching Jest and Vitest. */
154
+ const CLOSE_TO_DIGITS = 2;
155
+
156
+ /**
157
+ * A number within `10 ** -digits / 2` of `expected`.
158
+ *
159
+ * The same tolerance `toBeCloseTo` uses, so the asymmetric form and the matcher
160
+ * agree — a test that moves an assertion from one to the other should not have
161
+ * its verdict change.
162
+ */
163
+ export function closeTo(expected: number, digits: number = CLOSE_TO_DIGITS): AsymmetricMatcher {
164
+ const tolerance = 10 ** -digits / 2;
165
+ return matcher(`CloseTo(${expected}, ${digits})`, (received) => {
166
+ return typeof received === "number" && Math.abs(received - expected) < tolerance;
167
+ });
168
+ }
169
+
170
+ /** A matcher that holds when `inner` does not. */
171
+ export function not(inner: AsymmetricMatcher): AsymmetricMatcher {
172
+ return matcher(`Not(${inner.toString()})`, (received) => !matchesAsymmetric(inner, received));
173
+ }
174
+
175
+ /** A short rendering of an expected value, for a matcher's own name. */
176
+ function describe(value: mixed): string {
177
+ try {
178
+ return JSON.stringify(value) ?? String(value);
179
+ } catch {
180
+ // A cyclic or otherwise unserialisable expectation still has to have a
181
+ // name; what it is called matters less than that naming it cannot throw.
182
+ return String(value);
183
+ }
184
+ }
@@ -18,10 +18,12 @@
18
18
  // * `toEqual` ignores `undefined` properties and `toStrictEqual` does not,
19
19
  // which is the one place the two matchers differ besides prototypes.
20
20
 
21
+ import { isAsymmetric, matchesAsymmetric } from "./asymmetric.js";
22
+
21
23
  /** How strictly two values are compared. */
22
24
  export type Strictness = "loose" | "strict";
23
25
 
24
- type Pair = {| +left: mixed, +right: mixed |};
26
+ type Pair = {| readonly left: mixed, readonly right: mixed |};
25
27
 
26
28
  /** Longest rendering of one value inside a failure message. */
27
29
  export const MAX_RENDER_BYTES: number = 4096;
@@ -58,10 +60,15 @@ function ownKeys(value: interface {}, strictness: Strictness): Array<string | sy
58
60
  }
59
61
  // `toEqual` treats an absent property and one set to `undefined` as the
60
62
  // same thing, so a key holding `undefined` is not a difference.
61
- return keys.filter((key) => (value: $FlowFixMe)[key] !== undefined);
63
+ return keys.filter((key) => (value as $FlowFixMe)[key] !== undefined);
62
64
  }
63
65
 
64
- function sameSet(left: Set<mixed>, right: Set<mixed>, seen: Array<Pair>, strictness: Strictness): boolean {
66
+ function sameSet(
67
+ left: Set<mixed>,
68
+ right: Set<mixed>,
69
+ seen: Array<Pair>,
70
+ strictness: Strictness,
71
+ ): boolean {
65
72
  if (left.size !== right.size) {
66
73
  return false;
67
74
  }
@@ -114,6 +121,16 @@ export function equals(
114
121
  if (Object.is(left, right)) {
115
122
  return true;
116
123
  }
124
+ // A matcher stands in for a value rather than being one, and either side may
125
+ // be the expectation depending on which way round the caller passed them.
126
+ // Asking here rather than at the top level is what makes a matcher nested six
127
+ // levels down work for the same reason a top-level one does.
128
+ if (isAsymmetric(right)) {
129
+ return matchesAsymmetric(right, left);
130
+ }
131
+ if (isAsymmetric(left)) {
132
+ return matchesAsymmetric(left, right);
133
+ }
117
134
  if (!isObject(left) || !isObject(right)) {
118
135
  return false;
119
136
  }
@@ -150,14 +167,14 @@ export function equals(
150
167
  }
151
168
  if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) {
152
169
  const leftBytes = new Uint8Array(
153
- (left: $FlowFixMe).buffer,
154
- (left: $FlowFixMe).byteOffset,
155
- (left: $FlowFixMe).byteLength,
170
+ (left as $FlowFixMe).buffer,
171
+ (left as $FlowFixMe).byteOffset,
172
+ (left as $FlowFixMe).byteLength,
156
173
  );
157
174
  const rightBytes = new Uint8Array(
158
- (right: $FlowFixMe).buffer,
159
- (right: $FlowFixMe).byteOffset,
160
- (right: $FlowFixMe).byteLength,
175
+ (right as $FlowFixMe).buffer,
176
+ (right as $FlowFixMe).byteOffset,
177
+ (right as $FlowFixMe).byteLength,
161
178
  );
162
179
  if (leftBytes.length !== rightBytes.length) {
163
180
  return false;
@@ -176,8 +193,8 @@ export function equals(
176
193
  return false;
177
194
  }
178
195
 
179
- const leftKeys = ownKeys((left: $FlowFixMe), strictness);
180
- const rightKeys = ownKeys((right: $FlowFixMe), strictness);
196
+ const leftKeys = ownKeys(left as $FlowFixMe, strictness);
197
+ const rightKeys = ownKeys(right as $FlowFixMe, strictness);
181
198
  if (leftKeys.length !== rightKeys.length) {
182
199
  return false;
183
200
  }
@@ -185,7 +202,7 @@ export function equals(
185
202
  if (!Object.prototype.hasOwnProperty.call(right, key)) {
186
203
  return false;
187
204
  }
188
- if (!equals((left: $FlowFixMe)[key], (right: $FlowFixMe)[key], nested, strictness)) {
205
+ if (!equals((left as $FlowFixMe)[key], (right as $FlowFixMe)[key], nested, strictness)) {
189
206
  return false;
190
207
  }
191
208
  }
@@ -199,6 +216,9 @@ export function equals(
199
216
  * fine, missing or different ones are not.
200
217
  */
201
218
  export function matchesObject(received: mixed, expected: mixed, seen: Array<Pair> = []): boolean {
219
+ if (isAsymmetric(expected)) {
220
+ return matchesAsymmetric(expected, received);
221
+ }
202
222
  if (!isObject(expected) || !isObject(received)) {
203
223
  return equals(received, expected, seen);
204
224
  }
@@ -215,11 +235,11 @@ export function matchesObject(received: mixed, expected: mixed, seen: Array<Pair
215
235
  }
216
236
  return expected.every((item, index) => matchesObject(received[index], item, nested));
217
237
  }
218
- for (const key of ownKeys((expected: $FlowFixMe), "loose")) {
238
+ for (const key of ownKeys(expected as $FlowFixMe, "loose")) {
219
239
  if (!Object.prototype.hasOwnProperty.call(received, key)) {
220
240
  return false;
221
241
  }
222
- if (!matchesObject((received: $FlowFixMe)[key], (expected: $FlowFixMe)[key], nested)) {
242
+ if (!matchesObject((received as $FlowFixMe)[key], (expected as $FlowFixMe)[key], nested)) {
223
243
  return false;
224
244
  }
225
245
  }
@@ -238,6 +258,33 @@ function quoteString(value: string): string {
238
258
  * or refuses. Depth, breadth and total size are bounded, because a failure
239
259
  * message that scrolls the terminal is a failure message nobody reads.
240
260
  */
261
+ /**
262
+ * An element's opening tag, or `null` if this is not an element.
263
+ *
264
+ * Duck-typed rather than `instanceof Element`, because this module must not
265
+ * depend on a DOM existing: a test process without a document simply never has
266
+ * a value that answers to this shape.
267
+ */
268
+ function elementTag(value: mixed): string | null {
269
+ const node: $FlowFixMe = value;
270
+ if (
271
+ node == null ||
272
+ typeof node.tagName !== "string" ||
273
+ typeof node.getAttribute !== "function" ||
274
+ node.nodeType !== 1
275
+ ) {
276
+ return null;
277
+ }
278
+ const name = node.tagName.toLowerCase();
279
+ const attributes = Array.from(node.attributes ?? [])
280
+ .slice(0, MAX_RENDER_ENTRIES)
281
+ .map((attribute: $FlowFixMe) => ` ${attribute.name}="${attribute.value}"`)
282
+ .join("");
283
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
284
+ const shown = text.length > 40 ? `${text.slice(0, 40)}…` : text;
285
+ return shown === "" ? `<${name}${attributes} />` : `<${name}${attributes}>${shown}</${name}>`;
286
+ }
287
+
241
288
  export function render(value: mixed, depth: number = 0, seen: Array<mixed> = []): string {
242
289
  const text = renderInner(value, depth, seen);
243
290
  return text.length > MAX_RENDER_BYTES ? `${text.slice(0, MAX_RENDER_BYTES)}… (elided)` : text;
@@ -262,7 +309,7 @@ function renderInner(value: mixed, depth: number, seen: Array<mixed>): string {
262
309
  case "symbol":
263
310
  return String(value);
264
311
  case "function": {
265
- const name = (value: $FlowFixMe).name;
312
+ const name = (value as $FlowFixMe).name;
266
313
  return name === "" ? "[Function (anonymous)]" : `[Function ${name}]`;
267
314
  }
268
315
  default:
@@ -278,6 +325,14 @@ function renderInner(value: mixed, depth: number, seen: Array<mixed>): string {
278
325
  const nested = [...seen, value];
279
326
  const inner = (item: mixed) => renderInner(item, depth + 1, nested);
280
327
 
328
+ // An element, before the object branch reaches it. A DOM node's own
329
+ // properties are event-listener maps and parent pointers, so rendering it as
330
+ // an object buries the failure under a page of internals — and the whole
331
+ // document, through the parent chain. Its opening tag is what a reader needs.
332
+ const tag = elementTag(value);
333
+ if (tag != null) {
334
+ return tag;
335
+ }
281
336
  if (value instanceof Date) {
282
337
  return `Date(${value.toISOString()})`;
283
338
  }
@@ -289,19 +344,22 @@ function renderInner(value: mixed, depth: number, seen: Array<mixed>): string {
289
344
  }
290
345
  if (value instanceof Set) {
291
346
  const items = [...value].slice(0, MAX_RENDER_ENTRIES).map(inner);
292
- const more = value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
347
+ const more =
348
+ value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
293
349
  return `Set { ${items.join(", ")}${more} }`;
294
350
  }
295
351
  if (value instanceof Map) {
296
352
  const items = [...value]
297
353
  .slice(0, MAX_RENDER_ENTRIES)
298
354
  .map(([key, item]) => `${inner(key)} => ${inner(item)}`);
299
- const more = value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
355
+ const more =
356
+ value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
300
357
  return `Map { ${items.join(", ")}${more} }`;
301
358
  }
302
359
  if (Array.isArray(value)) {
303
360
  const items = value.slice(0, MAX_RENDER_ENTRIES).map(inner);
304
- const more = value.length > MAX_RENDER_ENTRIES ? `, …${value.length - MAX_RENDER_ENTRIES} more` : "";
361
+ const more =
362
+ value.length > MAX_RENDER_ENTRIES ? `, …${value.length - MAX_RENDER_ENTRIES} more` : "";
305
363
  return `[${items.join(", ")}${more}]`;
306
364
  }
307
365
 
@@ -318,5 +376,7 @@ function renderInner(value: mixed, depth: number, seen: Array<mixed>): string {
318
376
  prototype != null && prototype.constructor != null && prototype.constructor.name !== "Object"
319
377
  ? `${prototype.constructor.name} `
320
378
  : "";
321
- return entries.length === 0 ? `${constructorName}{}` : `${constructorName}{ ${entries.join(", ")}${more} }`;
379
+ return entries.length === 0
380
+ ? `${constructorName}{}`
381
+ : `${constructorName}{ ${entries.join(", ")}${more} }`;
322
382
  }