@uniflowed/test 0.0.0-alpha.12 → 0.0.0-alpha.14
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 +1 -0
- package/internal/expect.js +351 -75
- package/internal/namespace.js +13 -4
- package/package.json +2 -2
- package/worker.js +35 -0
package/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// The whole surface is importable from here, so a test file has one import.
|
|
14
14
|
|
|
15
|
+
export type { Expect, Expectation, Matchers } from "./internal/expect.js";
|
|
15
16
|
export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
|
|
16
17
|
export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
|
|
17
18
|
export type { Uft } from "./internal/namespace.js";
|
package/internal/expect.js
CHANGED
|
@@ -11,7 +11,61 @@
|
|
|
11
11
|
// `.resolves` and `.rejects` settle the promise first and then apply the same
|
|
12
12
|
// matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
|
|
13
13
|
// the way the synchronous form does.
|
|
14
|
+
//
|
|
15
|
+
// # The names are written down, and the behaviour is not
|
|
16
|
+
//
|
|
17
|
+
// `expect` used to be `$FlowFixMe`, and so was everything it handed back. That
|
|
18
|
+
// is a hole in the published type of the package a project writes every one of
|
|
19
|
+
// its assertions against: `expect(user).toBaa(1)` was not a misspelling
|
|
20
|
+
// anybody's checker would find, `expect(list).toHaveLength("3")` was not a type
|
|
21
|
+
// error, and `expect(p).resolves` on a value that is not a promise was fine
|
|
22
|
+
// until it ran. Every test in this repository is written against `expect`,
|
|
23
|
+
// which is the largest surface in the packages and was the least checked.
|
|
24
|
+
//
|
|
25
|
+
// So [`Matchers`] below writes the names out, one signature per matcher, the
|
|
26
|
+
// way `@uniflowed/react-testing`'s `Queries` writes out its thirty-six. Flow
|
|
27
|
+
// has no template literal types and no way to read a name out of a value, so
|
|
28
|
+
// the listing has to exist for the type to exist at all. What is *not*
|
|
29
|
+
// repeated is any behaviour: [`verdicts`] is still the one place a matcher is
|
|
30
|
+
// decided, and the listing is a naming that a reader can check against it by
|
|
31
|
+
// eye.
|
|
32
|
+
//
|
|
33
|
+
// [`Matchers`] is generic in what a matcher *returns*, which is what lets
|
|
34
|
+
// `.resolves` reuse the one listing: the same forty-one names, each handing
|
|
35
|
+
// back a promise.
|
|
36
|
+
//
|
|
37
|
+
// # The received value's type is not carried, and that was tried
|
|
38
|
+
//
|
|
39
|
+
// ubugeeei-prod/uf#402 asked for a second parameter as well — the type handed
|
|
40
|
+
// to `expect`, carried through the matchers so that `expect(count).toBe("two")`
|
|
41
|
+
// is an error at the call. It is written here rather than left for somebody to
|
|
42
|
+
// discover, because it looks obviously right and is not.
|
|
43
|
+
//
|
|
44
|
+
// `toBe` is `Object.is`, and identity is not assignability. Typing it
|
|
45
|
+
// `(expected: T)` demands that the expected value be a subtype of the received
|
|
46
|
+
// one, which is a direction the runtime has no opinion about and which ordinary
|
|
47
|
+
// assertions fail in both:
|
|
48
|
+
// `expect(document.activeElement).toBe(screen.getByRole("button"))` compares an
|
|
49
|
+
// `HTMLElement | null` with an `Element`, neither is the other's subtype, and
|
|
50
|
+
// that is the most common assertion in a DOM test. Jest and Vitest both type
|
|
51
|
+
// this argument as `unknown` for the same reason.
|
|
52
|
+
//
|
|
53
|
+
// Worse, the parameter has to be *inferred*, and `expect(x)` is where a lot of
|
|
54
|
+
// otherwise unconstrained expressions sit. `await
|
|
55
|
+
// expect(client.request("/users/1")).resolves.toEqual({ id: 1 })` stops
|
|
56
|
+
// checking and starts reporting that `request`'s own type parameter is
|
|
57
|
+
// underconstrained, because a `mixed` parameter asked nothing of the argument
|
|
58
|
+
// and a generic one asks it to be solved. `expect([])` becomes "cannot
|
|
59
|
+
// determine type of empty array literal". Both are correct tests, and the type
|
|
60
|
+
// that rejects them is worse than the type that missed a mistyped comparison.
|
|
61
|
+
//
|
|
62
|
+
// So the received value arrives as `mixed`, and what makes a matcher checked is
|
|
63
|
+
// its own signature: `toHaveLength` wants a number, `toMatch` a pattern,
|
|
64
|
+
// `toBeTypeOf` one of the eight words `typeof` answers with, and every name is
|
|
65
|
+
// a name. The comparison between two unrelated types stays unchecked, and it is
|
|
66
|
+
// the only part of the issue that does.
|
|
14
67
|
|
|
68
|
+
import type { AsymmetricMatcher } from "./asymmetric.js";
|
|
15
69
|
import type { SpyCall } from "./spy.js";
|
|
16
70
|
import * as asymmetric from "./asymmetric.js";
|
|
17
71
|
import * as snapshot from "./snapshot.js";
|
|
@@ -45,6 +99,152 @@ type Verdict = {|
|
|
|
45
99
|
readonly received?: string,
|
|
46
100
|
|};
|
|
47
101
|
|
|
102
|
+
/** What `typeof` can answer, for the matcher that compares against it. */
|
|
103
|
+
type TypeName =
|
|
104
|
+
| "bigint"
|
|
105
|
+
| "boolean"
|
|
106
|
+
| "function"
|
|
107
|
+
| "number"
|
|
108
|
+
| "object"
|
|
109
|
+
| "string"
|
|
110
|
+
| "symbol"
|
|
111
|
+
| "undefined";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every matcher, each returning `R`.
|
|
115
|
+
*
|
|
116
|
+
* Generic in the return type because the surface exists twice and that is the
|
|
117
|
+
* only thing that differs: `expect(x)` raises where a matcher fails and hands
|
|
118
|
+
* back nothing, while `expect(p).resolves` settles first and so hands back a
|
|
119
|
+
* promise. Writing the forty-one names once and saying what changes is the
|
|
120
|
+
* whole reason for the parameter — the alternative was the same list twice,
|
|
121
|
+
* with `=> void` on one copy and `=> Promise<void>` on the other, and a reader
|
|
122
|
+
* left to diff them.
|
|
123
|
+
*
|
|
124
|
+
* Where an argument is `mixed` it is because the runtime genuinely takes
|
|
125
|
+
* anything there and the checker would be lying to say otherwise: `toEqual`
|
|
126
|
+
* accepts an asymmetric matcher standing in for a value at any depth, and
|
|
127
|
+
* `toHaveValue` compares whatever a control is holding. Where it is not —
|
|
128
|
+
* `toHaveLength` wants a number, `toMatch` a string or a pattern, `toBeTypeOf`
|
|
129
|
+
* one of the eight words `typeof` produces — the narrower type is what the
|
|
130
|
+
* implementation already assumes, and saying it out loud is the point of the
|
|
131
|
+
* exercise.
|
|
132
|
+
*
|
|
133
|
+
* `not` is the same list again because negation is the only thing it changes.
|
|
134
|
+
* `resolves` and `rejects` are deliberately not here: they belong to
|
|
135
|
+
* [`Expectation`], because `expect(p).resolves.not` exists and
|
|
136
|
+
* `expect(x).not.resolves` does not.
|
|
137
|
+
*/
|
|
138
|
+
export type Matchers<R> = {
|
|
139
|
+
readonly toBe: (expected: mixed) => R,
|
|
140
|
+
readonly toEqual: (expected: mixed) => R,
|
|
141
|
+
readonly toStrictEqual: (expected: mixed) => R,
|
|
142
|
+
readonly toBeTruthy: () => R,
|
|
143
|
+
readonly toBeFalsy: () => R,
|
|
144
|
+
readonly toBeNull: () => R,
|
|
145
|
+
readonly toBeUndefined: () => R,
|
|
146
|
+
readonly toBeDefined: () => R,
|
|
147
|
+
readonly toBeNaN: () => R,
|
|
148
|
+
readonly toBeGreaterThan: (expected: number) => R,
|
|
149
|
+
readonly toBeGreaterThanOrEqual: (expected: number) => R,
|
|
150
|
+
readonly toBeLessThan: (expected: number) => R,
|
|
151
|
+
readonly toBeLessThanOrEqual: (expected: number) => R,
|
|
152
|
+
readonly toBeCloseTo: (expected: number, digits?: number) => R,
|
|
153
|
+
readonly toContain: (expected: mixed) => R,
|
|
154
|
+
readonly toContainEqual: (expected: mixed) => R,
|
|
155
|
+
readonly toHaveLength: (expected: number) => R,
|
|
156
|
+
readonly toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => R,
|
|
157
|
+
readonly toMatch: (expected: string | RegExp) => R,
|
|
158
|
+
readonly toMatchObject: (expected: mixed) => R,
|
|
159
|
+
readonly toBeInstanceOf: (expected: mixed) => R,
|
|
160
|
+
readonly toBeTypeOf: (expected: TypeName) => R,
|
|
161
|
+
readonly toSatisfy: (predicate: (value: mixed) => boolean) => R,
|
|
162
|
+
readonly toMatchSnapshot: (hint?: string) => R,
|
|
163
|
+
readonly toMatchInlineSnapshot: (expected?: string) => R,
|
|
164
|
+
readonly toThrow: (...rest: $ReadOnlyArray<mixed>) => R,
|
|
165
|
+
readonly toHaveBeenCalled: () => R,
|
|
166
|
+
readonly toHaveBeenCalledTimes: (count: number) => R,
|
|
167
|
+
readonly toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
|
|
168
|
+
readonly toHaveBeenLastCalledWith: (...args: $ReadOnlyArray<mixed>) => R,
|
|
169
|
+
readonly toBeInTheDocument: () => R,
|
|
170
|
+
readonly toBeVisible: () => R,
|
|
171
|
+
readonly toBeDisabled: () => R,
|
|
172
|
+
readonly toBeEnabled: () => R,
|
|
173
|
+
readonly toBeChecked: () => R,
|
|
174
|
+
readonly toBeRequired: () => R,
|
|
175
|
+
readonly toHaveFocus: () => R,
|
|
176
|
+
readonly toHaveAttribute: (name: string, value?: mixed) => R,
|
|
177
|
+
readonly toHaveClass: (...names: $ReadOnlyArray<string>) => R,
|
|
178
|
+
readonly toHaveTextContent: (expected: string | RegExp) => R,
|
|
179
|
+
readonly toHaveValue: (expected: mixed) => R,
|
|
180
|
+
readonly not: Matchers<R>,
|
|
181
|
+
...
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What `expect(received)` hands back.
|
|
186
|
+
*
|
|
187
|
+
* The matchers, plus the two that settle a promise before applying them. An
|
|
188
|
+
* intersection rather than a copy of the list with two lines added, and
|
|
189
|
+
* rather than an object spread, because a spread of an object type drops the
|
|
190
|
+
* `readonly` off every property it carries over — Flow computes a fresh object
|
|
191
|
+
* from the spread and the fresh one is writable, which would publish forty-one
|
|
192
|
+
* assignable matchers.
|
|
193
|
+
*
|
|
194
|
+
* # What is still not checked
|
|
195
|
+
*
|
|
196
|
+
* `expect(5).resolves` types, and fails when it runs. Saying otherwise needs
|
|
197
|
+
* `expect` to have two call signatures — one for a promise handing back a
|
|
198
|
+
* shape with `resolves`, one for everything else handing back a shape without
|
|
199
|
+
* — and Flow then requires the single function behind them to satisfy both,
|
|
200
|
+
* which no single function does. The overload is written down here rather than
|
|
201
|
+
* attempted because "it did not type" is the kind of thing that gets tried
|
|
202
|
+
* twice.
|
|
203
|
+
*/
|
|
204
|
+
export type Expectation = Matchers<void> & {
|
|
205
|
+
readonly resolves: Matchers<Promise<void>>,
|
|
206
|
+
readonly rejects: Matchers<Promise<void>>,
|
|
207
|
+
...
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `expect` itself: callable, and carrying the matchers that stand in for a
|
|
212
|
+
* value instead of being one.
|
|
213
|
+
*
|
|
214
|
+
* Inexact, and it has to be. The value is a function, every function has
|
|
215
|
+
* `name`, `length`, `call`, `apply` and `bind`, and an exact object type
|
|
216
|
+
* refuses one for exactly that reason. Inexactness costs nothing that matters
|
|
217
|
+
* here: Flow still reports a read of a property this type does not list, which
|
|
218
|
+
* is what makes `expect.anythign()` an error.
|
|
219
|
+
*/
|
|
220
|
+
export type Expect = {
|
|
221
|
+
(received: mixed): Expectation,
|
|
222
|
+
// `flow/unclear-type` reads source text rather than an AST, and the shape it
|
|
223
|
+
// recognises as a property key rather than a type is a name at the start of
|
|
224
|
+
// a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
|
|
225
|
+
// the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
|
|
226
|
+
// `any` type. The rule's own comment already lists `@uniflowed/test`'s
|
|
227
|
+
// `expect.any` among the false positives it exists to avoid; this is the one
|
|
228
|
+
// spelling it still cannot see past.
|
|
229
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
230
|
+
readonly any: (constructor: mixed) => AsymmetricMatcher,
|
|
231
|
+
readonly anything: () => AsymmetricMatcher,
|
|
232
|
+
readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
|
|
233
|
+
readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
|
|
234
|
+
readonly stringContaining: (substring: string) => AsymmetricMatcher,
|
|
235
|
+
readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
|
|
236
|
+
readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
|
|
237
|
+
readonly not: {
|
|
238
|
+
readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
|
|
239
|
+
readonly arrayContaining: (expected: $ReadOnlyArray<mixed>) => AsymmetricMatcher,
|
|
240
|
+
readonly stringContaining: (substring: string) => AsymmetricMatcher,
|
|
241
|
+
readonly stringMatching: (pattern: string | RegExp) => AsymmetricMatcher,
|
|
242
|
+
readonly closeTo: (value: number, digits?: number) => AsymmetricMatcher,
|
|
243
|
+
...
|
|
244
|
+
},
|
|
245
|
+
...
|
|
246
|
+
};
|
|
247
|
+
|
|
48
248
|
function propertyAt(
|
|
49
249
|
value: mixed,
|
|
50
250
|
path: string,
|
|
@@ -89,28 +289,36 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
|
|
|
89
289
|
* Every entry returns a [`Verdict`] rather than throwing, which is what lets
|
|
90
290
|
* `.not` reuse all of them.
|
|
91
291
|
*
|
|
92
|
-
* #
|
|
292
|
+
* # Every entry takes `mixed`, and that is what makes the indexer sayable
|
|
293
|
+
*
|
|
294
|
+
* [`bind`] reaches an entry by a computed key and applies it to the
|
|
295
|
+
* `$ReadOnlyArray<mixed>` it collected from the caller, so the indexer has to
|
|
296
|
+
* describe a function that will accept those arguments. Parameters are
|
|
297
|
+
* contravariant, so an entry that demanded a `number` could not be described
|
|
298
|
+
* by one — `(...args: $ReadOnlyArray<mixed>)` rejects it, and
|
|
299
|
+
* `(...args: $ReadOnlyArray<empty>)` accepts it and rejects the call. That
|
|
300
|
+
* disagreement is why this indexer used to be written `$ReadOnlyArray<any>`,
|
|
301
|
+
* with a `flow/unclear-type` suppression on it.
|
|
93
302
|
*
|
|
94
|
-
*
|
|
95
|
-
* `
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
303
|
+
* So the disagreement is gone instead of papered over: every entry here takes
|
|
304
|
+
* `mixed` and coerces what it needs, the way most of them — `toBe`,
|
|
305
|
+
* `toBeGreaterThan`, `toHaveAttribute` — already did. Nothing a caller can see
|
|
306
|
+
* got wider: `toHaveLength` still refuses a string and `toBeTypeOf` still
|
|
307
|
+
* refuses a word `typeof` never says, because those are [`Matchers`]'s
|
|
308
|
+
* signatures and [`Matchers`] is the published type. What changed is that the
|
|
309
|
+
* table behind them stopped claiming a narrower argument than the one `bind`
|
|
310
|
+
* can hand it, which is a claim that was never true. The `String(…)` and
|
|
311
|
+
* `Number(…)` calls that appeared with it are the coercion the runtime was
|
|
312
|
+
* already doing, said out loud, and each produces the same message the implicit
|
|
313
|
+
* one did for the same input.
|
|
101
314
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
|
|
107
|
-
* is what `expect`'s own annotation is waiting for, and is
|
|
108
|
-
* ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
|
|
109
|
-
* precision nobody can reach.
|
|
315
|
+
* The alternative — narrowing the indexer by writing `bind`'s forty-one
|
|
316
|
+
* wrappers out to avoid the computed lookup — is a second copy of the listing
|
|
317
|
+
* to keep in step with [`Matchers`], and is a worse trade than either. See
|
|
318
|
+
* ubugeeei-prod/uf#402.
|
|
110
319
|
*/
|
|
111
320
|
function verdicts(received: mixed): {
|
|
112
|
-
|
|
113
|
-
readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
|
|
321
|
+
readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict,
|
|
114
322
|
} {
|
|
115
323
|
const shown = () => render(received);
|
|
116
324
|
const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
|
|
@@ -185,14 +393,15 @@ function verdicts(received: mixed): {
|
|
|
185
393
|
`to be at most ${render(expected)}`,
|
|
186
394
|
expected,
|
|
187
395
|
),
|
|
188
|
-
toBeCloseTo: (expected:
|
|
189
|
-
const
|
|
396
|
+
toBeCloseTo: (expected: mixed, digits?: mixed) => {
|
|
397
|
+
const target = Number(expected);
|
|
398
|
+
const places = digits === undefined ? 2 : Number(digits);
|
|
190
399
|
const tolerance = 10 ** -places / 2;
|
|
191
|
-
const difference = Math.abs((received as $FlowFixMe) -
|
|
400
|
+
const difference = Math.abs((received as $FlowFixMe) - target);
|
|
192
401
|
return simple(
|
|
193
402
|
difference < tolerance,
|
|
194
|
-
`to be within ${tolerance} of ${
|
|
195
|
-
|
|
403
|
+
`to be within ${tolerance} of ${target}, but it is off by ${difference}`,
|
|
404
|
+
target,
|
|
196
405
|
);
|
|
197
406
|
},
|
|
198
407
|
toContain: (expected: mixed) => {
|
|
@@ -218,22 +427,23 @@ function verdicts(received: mixed): {
|
|
|
218
427
|
expected,
|
|
219
428
|
);
|
|
220
429
|
},
|
|
221
|
-
toHaveLength: (expected:
|
|
430
|
+
toHaveLength: (expected: mixed) => {
|
|
222
431
|
const length = received == null ? undefined : (received as $FlowFixMe).length;
|
|
223
432
|
return simple(
|
|
224
433
|
length === expected,
|
|
225
|
-
`to have length ${expected}, not ${render(length)}`,
|
|
434
|
+
`to have length ${String(expected)}, not ${render(length)}`,
|
|
226
435
|
expected,
|
|
227
436
|
);
|
|
228
437
|
},
|
|
229
|
-
toHaveProperty: (path:
|
|
230
|
-
const
|
|
438
|
+
toHaveProperty: (path: mixed, ...rest: $ReadOnlyArray<mixed>) => {
|
|
439
|
+
const at = String(path);
|
|
440
|
+
const found = propertyAt(received, at);
|
|
231
441
|
if (rest.length === 0) {
|
|
232
|
-
return simple(found.found, `to have a property at \`${
|
|
442
|
+
return simple(found.found, `to have a property at \`${at}\``);
|
|
233
443
|
}
|
|
234
444
|
return simple(
|
|
235
445
|
found.found && equals(found.value, rest[0]),
|
|
236
|
-
`to have \`${
|
|
446
|
+
`to have \`${at}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
|
|
237
447
|
rest[0],
|
|
238
448
|
);
|
|
239
449
|
},
|
|
@@ -253,16 +463,24 @@ function verdicts(received: mixed): {
|
|
|
253
463
|
`to be an instance of ${render(expected)}`,
|
|
254
464
|
expected,
|
|
255
465
|
),
|
|
256
|
-
toBeTypeOf: (expected:
|
|
466
|
+
toBeTypeOf: (expected: mixed) => {
|
|
467
|
+
const name = String(expected);
|
|
468
|
+
return simple(
|
|
469
|
+
typeof received === name,
|
|
470
|
+
`to be of type ${name}, not ${typeof received}`,
|
|
471
|
+
name,
|
|
472
|
+
);
|
|
473
|
+
},
|
|
474
|
+
toSatisfy: (predicate: mixed) =>
|
|
257
475
|
simple(
|
|
258
|
-
typeof received ===
|
|
259
|
-
|
|
260
|
-
expected,
|
|
476
|
+
typeof predicate === "function" && predicate(received) === true,
|
|
477
|
+
"to satisfy the predicate",
|
|
261
478
|
),
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
479
|
+
toMatchSnapshot: (hint?: mixed): Verdict => {
|
|
480
|
+
const verdict = snapshot.matchSnapshot(
|
|
481
|
+
received,
|
|
482
|
+
hint === undefined ? undefined : String(hint),
|
|
483
|
+
);
|
|
266
484
|
return {
|
|
267
485
|
pass: verdict.pass,
|
|
268
486
|
expected: verdict.expected ?? "(no snapshot yet)",
|
|
@@ -276,8 +494,11 @@ function verdicts(received: mixed): {
|
|
|
276
494
|
negatedFailure: () => "expected the value not to match its snapshot",
|
|
277
495
|
};
|
|
278
496
|
},
|
|
279
|
-
toMatchInlineSnapshot: (expected?:
|
|
280
|
-
const verdict = snapshot.matchInlineSnapshot(
|
|
497
|
+
toMatchInlineSnapshot: (expected?: mixed): Verdict => {
|
|
498
|
+
const verdict = snapshot.matchInlineSnapshot(
|
|
499
|
+
received,
|
|
500
|
+
expected === undefined ? undefined : String(expected),
|
|
501
|
+
);
|
|
281
502
|
return {
|
|
282
503
|
pass: verdict.pass,
|
|
283
504
|
expected: verdict.expected ?? "(no inline snapshot yet)",
|
|
@@ -328,10 +549,14 @@ function verdicts(received: mixed): {
|
|
|
328
549
|
requireSpy("toHaveBeenCalled");
|
|
329
550
|
return simple(spyCalls().length > 0, "to have been called");
|
|
330
551
|
},
|
|
331
|
-
toHaveBeenCalledTimes: (count:
|
|
552
|
+
toHaveBeenCalledTimes: (count: mixed) => {
|
|
332
553
|
requireSpy("toHaveBeenCalledTimes");
|
|
333
554
|
const actual = spyCalls().length;
|
|
334
|
-
return simple(
|
|
555
|
+
return simple(
|
|
556
|
+
actual === count,
|
|
557
|
+
`to have been called ${String(count)} times, not ${actual}`,
|
|
558
|
+
count,
|
|
559
|
+
);
|
|
335
560
|
},
|
|
336
561
|
toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => {
|
|
337
562
|
requireSpy("toHaveBeenCalledWith");
|
|
@@ -466,14 +691,42 @@ function verdicts(received: mixed): {
|
|
|
466
691
|
* Walks the ancestors, because `display: none` on a parent hides a child whose
|
|
467
692
|
* own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
|
|
468
693
|
* open each hide their subtree too.
|
|
694
|
+
*
|
|
695
|
+
* # The one thing a closed `<details>` still shows
|
|
696
|
+
*
|
|
697
|
+
* Its `<summary>`. A closed disclosure renders exactly one child and hides the
|
|
698
|
+
* rest, so the rule is "everything under a closed `<details>` except its
|
|
699
|
+
* summary" — and saying that needs the child the walk arrived from, not only
|
|
700
|
+
* the ancestor it is standing on. Without it the rule was written as "unless
|
|
701
|
+
* the `<details>` is the element being asked about", which exempted the
|
|
702
|
+
* disclosure from its own rule and left the summary inside it invisible:
|
|
703
|
+
* `expect(screen.getByText("More")).toBeVisible()` failed for the one thing on
|
|
704
|
+
* the screen, while the reader was looking at it.
|
|
705
|
+
*
|
|
706
|
+
* # Why this is not the walk in `react-testing`
|
|
707
|
+
*
|
|
708
|
+
* `packages/react-testing/internal/queries.js` has one that looks like this
|
|
709
|
+
* and answers a different question. `exposed` asks whether the accessibility
|
|
710
|
+
* tree announces the element, so it ignores `opacity: 0` — a screen reader
|
|
711
|
+
* reads text at zero opacity, which is exactly why hiding text that way is a
|
|
712
|
+
* bug rather than a technique — and it takes `aria-hidden` as decisive. This
|
|
713
|
+
* one asks whether a reader would *see* it, so the two answers part company
|
|
714
|
+
* there on purpose. The `<details>` half is the half they agree on, and it is
|
|
715
|
+
* written the same way in both.
|
|
469
716
|
*/
|
|
470
717
|
function isVisible(node: Element): boolean {
|
|
718
|
+
let child: $FlowFixMe = null;
|
|
471
719
|
let current: $FlowFixMe = node;
|
|
472
720
|
while (current != null && current.nodeType === 1) {
|
|
473
721
|
if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
|
|
474
722
|
return false;
|
|
475
723
|
}
|
|
476
|
-
if (
|
|
724
|
+
if (
|
|
725
|
+
child != null &&
|
|
726
|
+
current.tagName === "DETAILS" &&
|
|
727
|
+
!current.hasAttribute("open") &&
|
|
728
|
+
child.tagName !== "SUMMARY"
|
|
729
|
+
) {
|
|
477
730
|
return false;
|
|
478
731
|
}
|
|
479
732
|
const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
|
|
@@ -485,6 +738,7 @@ function isVisible(node: Element): boolean {
|
|
|
485
738
|
return false;
|
|
486
739
|
}
|
|
487
740
|
}
|
|
741
|
+
child = current;
|
|
488
742
|
current = current.parentElement;
|
|
489
743
|
}
|
|
490
744
|
return true;
|
|
@@ -510,6 +764,17 @@ function isDisabled(node: Element): boolean {
|
|
|
510
764
|
*
|
|
511
765
|
* `negated` decides which message a failing verdict raises, which is all of
|
|
512
766
|
* what `.not` is.
|
|
767
|
+
*
|
|
768
|
+
* # Why the object is built rather than written
|
|
769
|
+
*
|
|
770
|
+
* `.not` has to be reached lazily or building an expectation would build its
|
|
771
|
+
* negation, which would build *its* negation, forever. A lazily installed
|
|
772
|
+
* property is not something an object literal carries, so the value is
|
|
773
|
+
* completed with `Object.defineProperty` after it exists — and an object
|
|
774
|
+
* completed after the fact is not one Flow can check a literal against. That
|
|
775
|
+
* is what this `$FlowFixMe` is, and it now covers a construction rather than a
|
|
776
|
+
* published type: [`expectValue`] states the real one, and the checker holds
|
|
777
|
+
* every caller to it.
|
|
513
778
|
*/
|
|
514
779
|
function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
515
780
|
const table = verdicts(received);
|
|
@@ -591,8 +856,12 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
591
856
|
* expect(() => parse("")).toThrow(/empty/);
|
|
592
857
|
* await expect(load()).resolves.toHaveLength(3);
|
|
593
858
|
* ```
|
|
859
|
+
*
|
|
860
|
+
* Takes a `mixed` and hands back a written-out [`Expectation`]. What each
|
|
861
|
+
* matcher will accept is decided by its own signature rather than by what was
|
|
862
|
+
* received, for the reasons this module's header sets out.
|
|
594
863
|
*/
|
|
595
|
-
function expectValue(received: mixed):
|
|
864
|
+
function expectValue(received: mixed): Expectation {
|
|
596
865
|
const expectation: $FlowFixMe = bind(received, false);
|
|
597
866
|
Object.defineProperty(expectation, "resolves", {
|
|
598
867
|
get: () => settled(received, "resolve", false),
|
|
@@ -602,11 +871,45 @@ function expectValue(received: mixed): $FlowFixMe {
|
|
|
602
871
|
}
|
|
603
872
|
|
|
604
873
|
/**
|
|
605
|
-
*
|
|
874
|
+
* Build the callable that carries the asymmetric matchers.
|
|
606
875
|
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
876
|
+
* The statics are attached inside a builder rather than at the module's top
|
|
877
|
+
* level, the way `internal/registry.js` builds `describe`: a shipped module
|
|
878
|
+
* may only declare, import and export at its top level, and `expect.any = …`
|
|
879
|
+
* out here is a statement that runs when the module is imported.
|
|
880
|
+
*
|
|
881
|
+
* It was `Object.assign(expectValue, { … })`, which is what a reader expects
|
|
882
|
+
* and what does not type. Flow models `Object.assign` as returning the
|
|
883
|
+
* *target*, so the result of assigning matchers onto a function is still a
|
|
884
|
+
* function with no matchers on it — eight `prop-missing` errors saying so, and
|
|
885
|
+
* a `flow/unsafe-object-assign` suppression on top of them. Attaching to a
|
|
886
|
+
* local before it is returned is the same runtime value with none of that: the
|
|
887
|
+
* checker sees the statics arrive and holds the result to [`Expect`].
|
|
888
|
+
*/
|
|
889
|
+
function expecting(): Expect {
|
|
890
|
+
const api = (received: mixed): Expectation => expectValue(received);
|
|
891
|
+
api.any = asymmetric.any;
|
|
892
|
+
api.anything = asymmetric.anything;
|
|
893
|
+
api.objectContaining = asymmetric.objectContaining;
|
|
894
|
+
api.arrayContaining = asymmetric.arrayContaining;
|
|
895
|
+
api.stringContaining = asymmetric.stringContaining;
|
|
896
|
+
api.stringMatching = asymmetric.stringMatching;
|
|
897
|
+
api.closeTo = asymmetric.closeTo;
|
|
898
|
+
api.not = {
|
|
899
|
+
objectContaining: (expected: interface {}) =>
|
|
900
|
+
asymmetric.not(asymmetric.objectContaining(expected)),
|
|
901
|
+
arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
|
|
902
|
+
asymmetric.not(asymmetric.arrayContaining(expected)),
|
|
903
|
+
stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
|
|
904
|
+
stringMatching: (pattern: string | RegExp) =>
|
|
905
|
+
asymmetric.not(asymmetric.stringMatching(pattern)),
|
|
906
|
+
closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
|
|
907
|
+
};
|
|
908
|
+
return api;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Assert about a value.
|
|
610
913
|
*
|
|
611
914
|
* The `expect.*` half are the matchers that stand in for a value instead of
|
|
612
915
|
* being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
|
|
@@ -619,31 +922,4 @@ function expectValue(received: mixed): $FlowFixMe {
|
|
|
619
922
|
* than a negated assertion around the whole object, and is the form a suite
|
|
620
923
|
* being ported will already have.
|
|
621
924
|
*/
|
|
622
|
-
|
|
623
|
-
// produce this value: `expect` is a *function* with matchers hanging off it,
|
|
624
|
-
// and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
|
|
625
|
-
// call. `Object.assign` onto a callable is the only expression that makes one,
|
|
626
|
-
// and the alternative the rule is really warning about — `expect.any = …`
|
|
627
|
-
// afterwards — is the top-level statement the comment above rules out. What it
|
|
628
|
-
// mutates is a function this module declared six lines up and exports here; no
|
|
629
|
-
// object belonging to anybody else is touched.
|
|
630
|
-
// uf-lint-disable-next-line flow/unsafe-object-assign
|
|
631
|
-
export const expect: $FlowFixMe = Object.assign(expectValue, {
|
|
632
|
-
any: asymmetric.any,
|
|
633
|
-
anything: asymmetric.anything,
|
|
634
|
-
objectContaining: asymmetric.objectContaining,
|
|
635
|
-
arrayContaining: asymmetric.arrayContaining,
|
|
636
|
-
stringContaining: asymmetric.stringContaining,
|
|
637
|
-
stringMatching: asymmetric.stringMatching,
|
|
638
|
-
closeTo: asymmetric.closeTo,
|
|
639
|
-
not: {
|
|
640
|
-
objectContaining: (expected: interface {}) =>
|
|
641
|
-
asymmetric.not(asymmetric.objectContaining(expected)),
|
|
642
|
-
arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
|
|
643
|
-
asymmetric.not(asymmetric.arrayContaining(expected)),
|
|
644
|
-
stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
|
|
645
|
-
stringMatching: (pattern: string | RegExp) =>
|
|
646
|
-
asymmetric.not(asymmetric.stringMatching(pattern)),
|
|
647
|
-
closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
|
|
648
|
-
},
|
|
649
|
-
});
|
|
925
|
+
export const expect: Expect = expecting();
|
package/internal/namespace.js
CHANGED
|
@@ -59,10 +59,15 @@ function environment(): { [string]: string } | null {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Replace an environment variable for the rest of the
|
|
62
|
+
* Replace an environment variable for the rest of the file.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* The file, and not the test: `process.env` belongs to the process, so a stub
|
|
65
|
+
* stands until something puts it back. `./worker.js` does that between files,
|
|
66
|
+
* beside the spy registry it clears for the same reason — a worker serves many
|
|
67
|
+
* files, and a stub that outlived its file would be a test that passes because
|
|
68
|
+
* of another one, in a suite where which files share a worker is decided by a
|
|
69
|
+
* timings file. A case that wants a narrower scope calls `unstubAllEnvs` in an
|
|
70
|
+
* `afterEach`, which is also what makes the scope visible to a reader.
|
|
66
71
|
*/
|
|
67
72
|
export function stubEnv(name: string, value: string | void): void {
|
|
68
73
|
const env = environment();
|
|
@@ -97,7 +102,11 @@ export function unstubAllEnvs(): void {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
/**
|
|
100
|
-
* Replace a global for the rest of the
|
|
105
|
+
* Replace a global for the rest of the file.
|
|
106
|
+
*
|
|
107
|
+
* Undone by `unstubAllGlobals`, which `./worker.js` calls between files, for
|
|
108
|
+
* the reason [`stubEnv`] above gives: `globalThis` outlives every file that
|
|
109
|
+
* writes to it.
|
|
101
110
|
*
|
|
102
111
|
* Whether the global was the object's own property is recorded, because putting
|
|
103
112
|
* back an inherited one by assignment would leave a copy that shadows whatever
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/test",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.14",
|
|
4
4
|
"description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,6 @@
|
|
|
21
21
|
"internal"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
24
|
+
"@uniflowed/host": "0.0.0-alpha.14"
|
|
25
25
|
}
|
|
26
26
|
}
|
package/worker.js
CHANGED
|
@@ -56,6 +56,15 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
56
56
|
import { reset } from "./internal/registry.js";
|
|
57
57
|
import { resetModuleState } from "./internal/modules.js";
|
|
58
58
|
import { run } from "./internal/run.js";
|
|
59
|
+
import { unstubAllEnvs, unstubAllGlobals } from "./internal/namespace.js";
|
|
60
|
+
// Renamed at the door, for two reasons that agree. It reads as the resets
|
|
61
|
+
// beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
|
|
62
|
+
// verb-first, and so is what this does to the clock. And `useRealTimers` is
|
|
63
|
+
// not a React hook: it is uf's own timer control, which happens to be named
|
|
64
|
+
// the way every runner names it, and calling it bare in a plain function is
|
|
65
|
+
// a `react/hooks-rules` error on the name alone. A suppression would assert
|
|
66
|
+
// something about this call; the name is simply accurate.
|
|
67
|
+
import { useRealTimers as restoreRealClock } from "./internal/timers.js";
|
|
59
68
|
|
|
60
69
|
/** What `uf` sends for one file. */
|
|
61
70
|
type Request = {|
|
|
@@ -140,6 +149,32 @@ function write(event: { readonly [string]: mixed }): void {
|
|
|
140
149
|
async function runFile(request: Request, generation: number): Promise<void> {
|
|
141
150
|
const started = performance.now();
|
|
142
151
|
reset();
|
|
152
|
+
// Every environment variable and global the previous file replaced goes back
|
|
153
|
+
// too. A spy lives in the registry `reset` clears, but a stub is a write to
|
|
154
|
+
// something the whole process shares: `uft.stubEnv("NODE_ENV", "production")`
|
|
155
|
+
// stays set for every later file this worker serves, and `uf test` fans files
|
|
156
|
+
// across workers by size, so which files those are changes with the timings
|
|
157
|
+
// file. That is a suite whose result depends on its schedule — and worse, one
|
|
158
|
+
// whose failure names the file that read the value rather than the file that
|
|
159
|
+
// wrote it. See ubugeeei-prod/uf#417.
|
|
160
|
+
unstubAllEnvs();
|
|
161
|
+
unstubAllGlobals();
|
|
162
|
+
// And the clock goes back, whatever the previous file did with it. A spy
|
|
163
|
+
// lives in the registry `reset` clears; a fake clock is a write to the
|
|
164
|
+
// scheduling globals the whole process shares, so `uft.useFakeTimers()` in
|
|
165
|
+
// one file is still installed when the next one imports.
|
|
166
|
+
//
|
|
167
|
+
// Worse than a leaked value, and worse in a way that hides it. A leaked stub
|
|
168
|
+
// makes the next file read something wrong, which arrives as an assertion
|
|
169
|
+
// naming the value. A leaked clock makes the next file's `setTimeout` never
|
|
170
|
+
// fire — including the one `withTimeout` races each case against — so the
|
|
171
|
+
// file hangs with nothing on screen until `uf`'s own deadline kills the
|
|
172
|
+
// worker, and the report names the file that waited rather than the file
|
|
173
|
+
// that stopped time. See ubugeeei-prod/uf#581.
|
|
174
|
+
//
|
|
175
|
+
// Before the import rather than after the run, so a file that throws while
|
|
176
|
+
// loading still hands the next one a real clock.
|
|
177
|
+
restoreRealClock();
|
|
143
178
|
// Every module this file stood in for goes back, before the next file can
|
|
144
179
|
// import one of them and be handed the previous file's stand-in. A worker
|
|
145
180
|
// serves many files out of one module registry, so this is the difference
|