@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +21 -2
- package/internal/asymmetric.js +184 -0
- package/internal/equality.js +79 -19
- package/internal/expect.js +323 -81
- package/internal/frames.js +96 -4
- package/internal/modules.js +430 -0
- package/internal/namespace.js +287 -0
- package/internal/output.js +309 -0
- package/internal/registry.js +30 -27
- package/internal/run.js +94 -42
- package/internal/snapshot.js +306 -0
- package/internal/spy.js +236 -0
- package/internal/timers.js +362 -0
- package/internal/unsupported.js +30 -0
- package/package.json +5 -2
- package/worker.js +163 -25
package/internal/expect.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
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
14
|
|
|
15
|
+
import type { SpyCall } from "./spy.js";
|
|
16
|
+
import * as asymmetric from "./asymmetric.js";
|
|
17
|
+
import * as snapshot from "./snapshot.js";
|
|
18
|
+
import { isSpy } from "./spy.js";
|
|
15
19
|
import { equals, matchesObject, render } from "./equality.js";
|
|
16
20
|
|
|
17
21
|
/** Thrown when a matcher does not hold. */
|
|
@@ -34,78 +38,26 @@ export class AssertionError extends Error {
|
|
|
34
38
|
|
|
35
39
|
/** What a matcher decided, and how to say it either way. */
|
|
36
40
|
type Verdict = {|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
readonly pass: boolean,
|
|
42
|
+
readonly failure: () => string,
|
|
43
|
+
readonly negatedFailure: () => string,
|
|
44
|
+
readonly expected?: string,
|
|
45
|
+
readonly received?: string,
|
|
42
46
|
|};
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
+threw?: mixed,
|
|
49
|
-
|};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* A spy that records its calls.
|
|
53
|
-
*
|
|
54
|
-
* `fn()` records and returns `undefined`; `fn(implementation)` records and
|
|
55
|
-
* delegates. A throw is recorded and then re-thrown, so wrapping a function in
|
|
56
|
-
* a spy never changes whether the code under test fails.
|
|
57
|
-
*/
|
|
58
|
-
export function fn(implementation?: (...args: $ReadOnlyArray<mixed>) => mixed): $FlowFixMe {
|
|
59
|
-
const calls: Array<SpyCall> = [];
|
|
60
|
-
let current = implementation;
|
|
61
|
-
|
|
62
|
-
const spy: $FlowFixMe = (...args: $ReadOnlyArray<mixed>) => {
|
|
63
|
-
try {
|
|
64
|
-
const returned = current == null ? undefined : current(...args);
|
|
65
|
-
calls.push({ args, returned });
|
|
66
|
-
return returned;
|
|
67
|
-
} catch (thrown) {
|
|
68
|
-
calls.push({ args, threw: thrown });
|
|
69
|
-
throw thrown;
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
spy.mock = { calls };
|
|
73
|
-
spy.mockClear = () => {
|
|
74
|
-
calls.length = 0;
|
|
75
|
-
};
|
|
76
|
-
spy.mockReturnValue = (value: mixed) => {
|
|
77
|
-
current = () => value;
|
|
78
|
-
return spy;
|
|
79
|
-
};
|
|
80
|
-
spy.mockResolvedValue = (value: mixed) => {
|
|
81
|
-
current = () => Promise.resolve(value);
|
|
82
|
-
return spy;
|
|
83
|
-
};
|
|
84
|
-
spy.mockRejectedValue = (reason: mixed) => {
|
|
85
|
-
current = () => Promise.reject(reason);
|
|
86
|
-
return spy;
|
|
87
|
-
};
|
|
88
|
-
spy.mockImplementation = (next: (...args: $ReadOnlyArray<mixed>) => mixed) => {
|
|
89
|
-
current = next;
|
|
90
|
-
return spy;
|
|
91
|
-
};
|
|
92
|
-
return spy;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function isSpy(value: mixed): boolean {
|
|
96
|
-
return typeof value === "function" && (value: $FlowFixMe).mock != null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function propertyAt(value: mixed, path: string): {| +found: boolean, +value: mixed |} {
|
|
48
|
+
function propertyAt(
|
|
49
|
+
value: mixed,
|
|
50
|
+
path: string,
|
|
51
|
+
): {| readonly found: boolean, readonly value: mixed |} {
|
|
100
52
|
let current = value;
|
|
101
53
|
for (const key of path.split(".")) {
|
|
102
54
|
if (current == null) {
|
|
103
55
|
return { found: false, value: undefined };
|
|
104
56
|
}
|
|
105
|
-
if (!Object.prototype.hasOwnProperty.call(
|
|
57
|
+
if (!Object.prototype.hasOwnProperty.call(current as $FlowFixMe, key)) {
|
|
106
58
|
return { found: false, value: undefined };
|
|
107
59
|
}
|
|
108
|
-
current = (current
|
|
60
|
+
current = (current as $FlowFixMe)[key];
|
|
109
61
|
}
|
|
110
62
|
return { found: true, value: current };
|
|
111
63
|
}
|
|
@@ -126,7 +78,7 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
|
|
|
126
78
|
return message === expected.message;
|
|
127
79
|
}
|
|
128
80
|
if (typeof expected === "function") {
|
|
129
|
-
return thrown instanceof (expected
|
|
81
|
+
return thrown instanceof (expected as $FlowFixMe);
|
|
130
82
|
}
|
|
131
83
|
return equals(thrown, expected);
|
|
132
84
|
}
|
|
@@ -136,8 +88,30 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
|
|
|
136
88
|
*
|
|
137
89
|
* Every entry returns a [`Verdict`] rather than throwing, which is what lets
|
|
138
90
|
* `.not` reuse all of them.
|
|
91
|
+
*
|
|
92
|
+
* # The `any` in the indexer
|
|
93
|
+
*
|
|
94
|
+
* The entries do not agree about their arguments — `toBe` takes a `mixed`,
|
|
95
|
+
* `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
|
|
96
|
+
* applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
|
|
97
|
+
* collected from a caller. Parameters are contravariant, so one indexer cannot
|
|
98
|
+
* describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
|
|
99
|
+
* that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
|
|
100
|
+
* entry and rejects the call.
|
|
101
|
+
*
|
|
102
|
+
* `mixed` with a cast at the call would move the same unsoundness one line
|
|
103
|
+
* without checking anything, because the caller is `bind`, whose result is
|
|
104
|
+
* `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
|
|
105
|
+
* type that makes any of this checked is a written-out matcher interface —
|
|
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.
|
|
139
110
|
*/
|
|
140
|
-
function verdicts(received: mixed): {
|
|
111
|
+
function verdicts(received: mixed): {
|
|
112
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
113
|
+
readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
|
|
114
|
+
} {
|
|
141
115
|
const shown = () => render(received);
|
|
142
116
|
const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
|
|
143
117
|
pass,
|
|
@@ -146,7 +120,8 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
146
120
|
failure: () => `expected ${shown()} ${what}`,
|
|
147
121
|
negatedFailure: () => `expected ${shown()} not ${what}`,
|
|
148
122
|
});
|
|
149
|
-
const spyCalls = (): Array<SpyCall> =>
|
|
123
|
+
const spyCalls = (): Array<SpyCall> =>
|
|
124
|
+
isSpy(received) ? (received as $FlowFixMe).mock.calls : [];
|
|
150
125
|
const requireSpy = (matcher: string) => {
|
|
151
126
|
if (!isSpy(received)) {
|
|
152
127
|
throw new AssertionError(
|
|
@@ -187,17 +162,33 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
187
162
|
toBeDefined: () => simple(received !== undefined, "to be defined"),
|
|
188
163
|
toBeNaN: () => simple(typeof received === "number" && Number.isNaN(received), "to be NaN"),
|
|
189
164
|
toBeGreaterThan: (expected: mixed) =>
|
|
190
|
-
simple(
|
|
165
|
+
simple(
|
|
166
|
+
(received as $FlowFixMe) > (expected as $FlowFixMe),
|
|
167
|
+
`to be greater than ${render(expected)}`,
|
|
168
|
+
expected,
|
|
169
|
+
),
|
|
191
170
|
toBeGreaterThanOrEqual: (expected: mixed) =>
|
|
192
|
-
simple(
|
|
171
|
+
simple(
|
|
172
|
+
(received as $FlowFixMe) >= (expected as $FlowFixMe),
|
|
173
|
+
`to be at least ${render(expected)}`,
|
|
174
|
+
expected,
|
|
175
|
+
),
|
|
193
176
|
toBeLessThan: (expected: mixed) =>
|
|
194
|
-
simple(
|
|
177
|
+
simple(
|
|
178
|
+
(received as $FlowFixMe) < (expected as $FlowFixMe),
|
|
179
|
+
`to be less than ${render(expected)}`,
|
|
180
|
+
expected,
|
|
181
|
+
),
|
|
195
182
|
toBeLessThanOrEqual: (expected: mixed) =>
|
|
196
|
-
simple(
|
|
183
|
+
simple(
|
|
184
|
+
(received as $FlowFixMe) <= (expected as $FlowFixMe),
|
|
185
|
+
`to be at most ${render(expected)}`,
|
|
186
|
+
expected,
|
|
187
|
+
),
|
|
197
188
|
toBeCloseTo: (expected: number, digits?: number) => {
|
|
198
189
|
const places = digits ?? 2;
|
|
199
190
|
const tolerance = 10 ** -places / 2;
|
|
200
|
-
const difference = Math.abs((received
|
|
191
|
+
const difference = Math.abs((received as $FlowFixMe) - expected);
|
|
201
192
|
return simple(
|
|
202
193
|
difference < tolerance,
|
|
203
194
|
`to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
|
|
@@ -216,7 +207,11 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
216
207
|
return simple(pass, `to contain ${render(expected)}`, expected);
|
|
217
208
|
},
|
|
218
209
|
toContainEqual: (expected: mixed) => {
|
|
219
|
-
const items = Array.isArray(received)
|
|
210
|
+
const items = Array.isArray(received)
|
|
211
|
+
? received
|
|
212
|
+
: received instanceof Set
|
|
213
|
+
? [...received]
|
|
214
|
+
: [];
|
|
220
215
|
return simple(
|
|
221
216
|
items.some((item) => equals(item, expected)),
|
|
222
217
|
`to contain something equal to ${render(expected)}`,
|
|
@@ -224,8 +219,12 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
224
219
|
);
|
|
225
220
|
},
|
|
226
221
|
toHaveLength: (expected: number) => {
|
|
227
|
-
const length = received == null ? undefined : (received
|
|
228
|
-
return simple(
|
|
222
|
+
const length = received == null ? undefined : (received as $FlowFixMe).length;
|
|
223
|
+
return simple(
|
|
224
|
+
length === expected,
|
|
225
|
+
`to have length ${expected}, not ${render(length)}`,
|
|
226
|
+
expected,
|
|
227
|
+
);
|
|
229
228
|
},
|
|
230
229
|
toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
|
|
231
230
|
const found = propertyAt(received, path);
|
|
@@ -240,21 +239,60 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
240
239
|
},
|
|
241
240
|
toMatch: (expected: mixed) => {
|
|
242
241
|
const text = typeof received === "string" ? received : String(received);
|
|
243
|
-
const pass =
|
|
242
|
+
const pass =
|
|
243
|
+
typeof expected === "string"
|
|
244
|
+
? text.includes(expected)
|
|
245
|
+
: (expected as $FlowFixMe).test(text);
|
|
244
246
|
return simple(pass, `to match ${render(expected)}`, expected);
|
|
245
247
|
},
|
|
246
248
|
toMatchObject: (expected: mixed) =>
|
|
247
249
|
simple(matchesObject(received, expected), `to match ${render(expected)}`, expected),
|
|
248
250
|
toBeInstanceOf: (expected: mixed) =>
|
|
249
251
|
simple(
|
|
250
|
-
typeof expected === "function" && received instanceof (expected
|
|
252
|
+
typeof expected === "function" && received instanceof (expected as $FlowFixMe),
|
|
251
253
|
`to be an instance of ${render(expected)}`,
|
|
252
254
|
expected,
|
|
253
255
|
),
|
|
254
256
|
toBeTypeOf: (expected: string) =>
|
|
255
|
-
simple(
|
|
257
|
+
simple(
|
|
258
|
+
typeof received === expected,
|
|
259
|
+
`to be of type ${expected}, not ${typeof received}`,
|
|
260
|
+
expected,
|
|
261
|
+
),
|
|
256
262
|
toSatisfy: (predicate: (value: mixed) => boolean) =>
|
|
257
263
|
simple(predicate(received) === true, "to satisfy the predicate"),
|
|
264
|
+
toMatchSnapshot: (hint?: string): Verdict => {
|
|
265
|
+
const verdict = snapshot.matchSnapshot(received, hint);
|
|
266
|
+
return {
|
|
267
|
+
pass: verdict.pass,
|
|
268
|
+
expected: verdict.expected ?? "(no snapshot yet)",
|
|
269
|
+
received: verdict.received,
|
|
270
|
+
// The whole of both sides, because a mismatch that says only "the
|
|
271
|
+
// snapshot did not match" makes a reader open two files.
|
|
272
|
+
failure: () =>
|
|
273
|
+
`snapshot did not match.\n\nstored:\n${verdict.expected ?? "(none)"}\n\n` +
|
|
274
|
+
`received:\n${verdict.received}\n\n` +
|
|
275
|
+
"Run `uf test -u` if the new value is the right one.",
|
|
276
|
+
negatedFailure: () => "expected the value not to match its snapshot",
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
toMatchInlineSnapshot: (expected?: string): Verdict => {
|
|
280
|
+
const verdict = snapshot.matchInlineSnapshot(received, expected);
|
|
281
|
+
return {
|
|
282
|
+
pass: verdict.pass,
|
|
283
|
+
expected: verdict.expected ?? "(no inline snapshot yet)",
|
|
284
|
+
received: verdict.received,
|
|
285
|
+
failure: () =>
|
|
286
|
+
verdict.expected == null
|
|
287
|
+
? // uf does not rewrite a test file — a tool that edits the file you
|
|
288
|
+
// are editing is a tool that loses work — so it reports what to
|
|
289
|
+
// paste in and leaves the decision to a person.
|
|
290
|
+
`no inline snapshot yet. Paste this into the call:\n\n\`\`\`\n${verdict.received}\n\`\`\``
|
|
291
|
+
: `inline snapshot did not match.\n\nstored:\n${verdict.expected}\n\n` +
|
|
292
|
+
`received:\n${verdict.received}`,
|
|
293
|
+
negatedFailure: () => "expected the value not to match its inline snapshot",
|
|
294
|
+
};
|
|
295
|
+
},
|
|
258
296
|
toThrow: (...rest: $ReadOnlyArray<mixed>) => {
|
|
259
297
|
const expected = rest[0];
|
|
260
298
|
if (typeof received !== "function") {
|
|
@@ -314,7 +352,157 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
|
|
|
314
352
|
args,
|
|
315
353
|
);
|
|
316
354
|
},
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------- //
|
|
357
|
+
// Elements
|
|
358
|
+
//
|
|
359
|
+
// These read properties of whatever they are given, so this module still
|
|
360
|
+
// needs no DOM and no dependency on one: an element is an object with a
|
|
361
|
+
// `tagName`, and a process without a document simply never has one to
|
|
362
|
+
// pass in. `element` says so when it does not.
|
|
363
|
+
// ---------------------------------------------------------------- //
|
|
364
|
+
|
|
365
|
+
toBeInTheDocument: () => {
|
|
366
|
+
const node = element("toBeInTheDocument");
|
|
367
|
+
const root = node.ownerDocument;
|
|
368
|
+
return simple(root != null && root.contains(node), "to be in the document");
|
|
369
|
+
},
|
|
370
|
+
toBeVisible: () => {
|
|
371
|
+
const node = element("toBeVisible");
|
|
372
|
+
return simple(isVisible(node), "to be visible");
|
|
373
|
+
},
|
|
374
|
+
toBeDisabled: () => {
|
|
375
|
+
const node = element("toBeDisabled");
|
|
376
|
+
return simple(isDisabled(node), "to be disabled");
|
|
377
|
+
},
|
|
378
|
+
toBeEnabled: () => {
|
|
379
|
+
const node = element("toBeEnabled");
|
|
380
|
+
return simple(!isDisabled(node), "to be enabled");
|
|
381
|
+
},
|
|
382
|
+
toBeChecked: () => {
|
|
383
|
+
const node = element("toBeChecked");
|
|
384
|
+
const aria = node.getAttribute("aria-checked");
|
|
385
|
+
const checked = aria != null ? aria === "true" : (node as $FlowFixMe).checked === true;
|
|
386
|
+
return simple(checked, "to be checked");
|
|
387
|
+
},
|
|
388
|
+
toBeRequired: () => {
|
|
389
|
+
const node = element("toBeRequired");
|
|
390
|
+
return simple(
|
|
391
|
+
(node as $FlowFixMe).required === true || node.getAttribute("aria-required") === "true",
|
|
392
|
+
"to be required",
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
toHaveFocus: () => {
|
|
396
|
+
const node = element("toHaveFocus");
|
|
397
|
+
return simple(node.ownerDocument?.activeElement === node, "to have focus");
|
|
398
|
+
},
|
|
399
|
+
toHaveAttribute: (name: mixed, value?: mixed) => {
|
|
400
|
+
const node = element("toHaveAttribute");
|
|
401
|
+
const actual = node.getAttribute(String(name));
|
|
402
|
+
if (value === undefined) {
|
|
403
|
+
return simple(actual != null, `to have the attribute ${render(name)}`, name);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
pass: actual === String(value),
|
|
407
|
+
expected: render(value),
|
|
408
|
+
received: render(actual),
|
|
409
|
+
failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
|
|
413
|
+
const node = element("toHaveClass");
|
|
414
|
+
const classes = (node.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
|
|
415
|
+
const wanted = names.map(String);
|
|
416
|
+
return {
|
|
417
|
+
pass: wanted.every((name) => classes.includes(name)),
|
|
418
|
+
expected: render(wanted),
|
|
419
|
+
received: render(classes),
|
|
420
|
+
failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
toHaveTextContent: (expected: mixed) => {
|
|
424
|
+
const node = element("toHaveTextContent");
|
|
425
|
+
const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
426
|
+
const pass =
|
|
427
|
+
expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
|
|
428
|
+
return {
|
|
429
|
+
pass,
|
|
430
|
+
expected: render(expected),
|
|
431
|
+
received: render(text),
|
|
432
|
+
failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
|
|
433
|
+
};
|
|
434
|
+
},
|
|
435
|
+
toHaveValue: (expected: mixed) => {
|
|
436
|
+
const node = element("toHaveValue");
|
|
437
|
+
const actual = (node as $FlowFixMe).value;
|
|
438
|
+
return {
|
|
439
|
+
pass: equals(actual, expected),
|
|
440
|
+
expected: render(expected),
|
|
441
|
+
received: render(actual),
|
|
442
|
+
failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
|
|
443
|
+
};
|
|
444
|
+
},
|
|
317
445
|
};
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The received value as an element, or a failure that says what it was.
|
|
449
|
+
*
|
|
450
|
+
* An element matcher applied to a string is almost always a query whose
|
|
451
|
+
* result was used without being awaited, and "received a Promise" is a much
|
|
452
|
+
* better message than a `TypeError` about `getAttribute`.
|
|
453
|
+
*/
|
|
454
|
+
function element(matcher: string): Element {
|
|
455
|
+
const node: $FlowFixMe = received;
|
|
456
|
+
if (node == null || typeof node.getAttribute !== "function") {
|
|
457
|
+
throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
|
|
458
|
+
}
|
|
459
|
+
return node;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Whether a reader would see this element.
|
|
465
|
+
*
|
|
466
|
+
* Walks the ancestors, because `display: none` on a parent hides a child whose
|
|
467
|
+
* own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
|
|
468
|
+
* open each hide their subtree too.
|
|
469
|
+
*/
|
|
470
|
+
function isVisible(node: Element): boolean {
|
|
471
|
+
let current: $FlowFixMe = node;
|
|
472
|
+
while (current != null && current.nodeType === 1) {
|
|
473
|
+
if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
|
|
480
|
+
if (style != null) {
|
|
481
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
if (style.opacity === "0") {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
current = current.parentElement;
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Whether the control is disabled, by its own attribute or a fieldset's. */
|
|
494
|
+
function isDisabled(node: Element): boolean {
|
|
495
|
+
let current: $FlowFixMe = node;
|
|
496
|
+
while (current != null && current.nodeType === 1) {
|
|
497
|
+
if (current.hasAttribute("disabled")) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
if (current.getAttribute("aria-disabled") === "true") {
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
current = current.parentElement;
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
318
506
|
}
|
|
319
507
|
|
|
320
508
|
/**
|
|
@@ -333,7 +521,12 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
|
333
521
|
return undefined;
|
|
334
522
|
}
|
|
335
523
|
const message = negated ? verdict.negatedFailure() : verdict.failure();
|
|
336
|
-
throw new AssertionError(
|
|
524
|
+
throw new AssertionError(
|
|
525
|
+
message,
|
|
526
|
+
name,
|
|
527
|
+
verdict.expected ?? "",
|
|
528
|
+
verdict.received ?? render(received),
|
|
529
|
+
);
|
|
337
530
|
};
|
|
338
531
|
}
|
|
339
532
|
Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
|
|
@@ -351,7 +544,7 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
351
544
|
let value: mixed;
|
|
352
545
|
let rejected = false;
|
|
353
546
|
try {
|
|
354
|
-
value = await (promise
|
|
547
|
+
value = await (promise as $FlowFixMe);
|
|
355
548
|
} catch (error) {
|
|
356
549
|
rejected = true;
|
|
357
550
|
value = error;
|
|
@@ -399,9 +592,58 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
399
592
|
* await expect(load()).resolves.toHaveLength(3);
|
|
400
593
|
* ```
|
|
401
594
|
*/
|
|
402
|
-
|
|
595
|
+
function expectValue(received: mixed): $FlowFixMe {
|
|
403
596
|
const expectation: $FlowFixMe = bind(received, false);
|
|
404
|
-
Object.defineProperty(expectation, "resolves", {
|
|
597
|
+
Object.defineProperty(expectation, "resolves", {
|
|
598
|
+
get: () => settled(received, "resolve", false),
|
|
599
|
+
});
|
|
405
600
|
Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
|
|
406
601
|
return expectation;
|
|
407
602
|
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Assert about a value.
|
|
606
|
+
*
|
|
607
|
+
* Declared with its matchers attached rather than assigned afterwards: a
|
|
608
|
+
* shipped module may only declare, import and export at its top level, and
|
|
609
|
+
* `expect.any = …` is a statement that runs when the module is imported.
|
|
610
|
+
*
|
|
611
|
+
* The `expect.*` half are the matchers that stand in for a value instead of
|
|
612
|
+
* being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
|
|
613
|
+
* says what a test means; spelling out the id would either be a lie or a second
|
|
614
|
+
* source of truth. They work at any depth, because `equals` asks every value it
|
|
615
|
+
* meets whether it is one.
|
|
616
|
+
*
|
|
617
|
+
* `expect.not.*` is the negated form, spelled the way Jest and Vitest spell it
|
|
618
|
+
* — `expect.not.objectContaining({ error: expect.anything() })` reads better
|
|
619
|
+
* than a negated assertion around the whole object, and is the form a suite
|
|
620
|
+
* being ported will already have.
|
|
621
|
+
*/
|
|
622
|
+
// `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
|
|
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
|
+
});
|
package/internal/frames.js
CHANGED
|
@@ -14,13 +14,26 @@
|
|
|
14
14
|
// regex-shaped.
|
|
15
15
|
|
|
16
16
|
/** A position in a source file, one-based line and column. */
|
|
17
|
-
export type Site = {|
|
|
17
|
+
export type Site = {| readonly line: number, readonly column: number |};
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Frames belonging to the testing libraries, which no test author wrote.
|
|
21
|
+
*
|
|
22
|
+
* `@uniflowed/react-testing` is here for exactly the reason `@uniflowed/test`
|
|
23
|
+
* is, and was missing because it is a different package. Every `getBy…`
|
|
24
|
+
* failure is *constructed* inside it, so the first surviving frame of the
|
|
25
|
+
* commonest failure a component test can produce was `internal/queries.js` —
|
|
26
|
+
* and since a site is reported as a line of the file being run, the reader was
|
|
27
|
+
* sent to a line their own file does not have. That is ubugeeei-prod/uf#319.
|
|
28
|
+
* A library that raises on the caller's behalf is the runner as far as the
|
|
29
|
+
* report is concerned.
|
|
30
|
+
*/
|
|
20
31
|
const INTERNAL_MARKERS = [
|
|
21
32
|
"/packages/test/internal/",
|
|
22
33
|
"/packages/test/worker.js",
|
|
34
|
+
"/packages/react-testing/",
|
|
23
35
|
"/@uniflowed/test/",
|
|
36
|
+
"/@uniflowed/react-testing/",
|
|
24
37
|
"node:internal/",
|
|
25
38
|
];
|
|
26
39
|
|
|
@@ -53,8 +66,54 @@ export function frameSite(frame: string): Site | null {
|
|
|
53
66
|
return { line: line.value, column: column.value };
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The file a stack frame names, or `null`.
|
|
71
|
+
*
|
|
72
|
+
* The same scan as [`frameSite`], stopping one step earlier: everything before
|
|
73
|
+
* the `:line:column` is where the code is, and what that is depends on how V8
|
|
74
|
+
* wrote the frame — `at name (/path:1:2)` when it has a function name, and
|
|
75
|
+
* `at /path:1:2` or `at async file:///path:1:2` when it does not.
|
|
76
|
+
*
|
|
77
|
+
* What comes back is whatever the frame said, a path or a URL, because those
|
|
78
|
+
* are the two things it can be and a caller resolving a module specifier
|
|
79
|
+
* against it has to tell them apart anyway.
|
|
80
|
+
*/
|
|
81
|
+
export function frameFile(frame: string): string | null {
|
|
82
|
+
let end = frame.length;
|
|
83
|
+
while (end > 0 && (frame[end - 1] === " " || frame[end - 1] === ")")) {
|
|
84
|
+
end -= 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const column = digitsBefore(frame, end);
|
|
88
|
+
if (column == null || column.start === 0 || frame[column.start - 1] !== ":") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const line = digitsBefore(frame, column.start - 1);
|
|
92
|
+
if (line == null || line.start === 0 || frame[line.start - 1] !== ":") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let text = frame.slice(0, line.start - 1);
|
|
97
|
+
const open = text.lastIndexOf("(");
|
|
98
|
+
if (open !== -1) {
|
|
99
|
+
text = text.slice(open + 1);
|
|
100
|
+
} else {
|
|
101
|
+
const at = text.lastIndexOf(" at ");
|
|
102
|
+
text = at === -1 ? text : text.slice(at + 4);
|
|
103
|
+
}
|
|
104
|
+
text = text.trim();
|
|
105
|
+
// `at async /path:1:2` — the marker belongs to the frame, not to the file.
|
|
106
|
+
if (text.startsWith("async ")) {
|
|
107
|
+
text = text.slice("async ".length).trim();
|
|
108
|
+
}
|
|
109
|
+
return text === "" ? null : text;
|
|
110
|
+
}
|
|
111
|
+
|
|
56
112
|
/** The run of digits ending at `end`, with where it starts. */
|
|
57
|
-
function digitsBefore(
|
|
113
|
+
function digitsBefore(
|
|
114
|
+
text: string,
|
|
115
|
+
end: number,
|
|
116
|
+
): {| readonly value: number, readonly start: number |} | null {
|
|
58
117
|
let start = end;
|
|
59
118
|
while (start > 0 && text[start - 1] >= "0" && text[start - 1] <= "9") {
|
|
60
119
|
start -= 1;
|
|
@@ -71,7 +130,10 @@ function digitsBefore(text: string, end: number): {| +value: number, +start: num
|
|
|
71
130
|
* `skipInternal` is false when the caller has already trimmed the runner's own
|
|
72
131
|
* frames and wants the first frame whatever it is.
|
|
73
132
|
*/
|
|
74
|
-
export function firstUserSite(
|
|
133
|
+
export function firstUserSite(
|
|
134
|
+
stack: string | null | void,
|
|
135
|
+
skipInternal: boolean = true,
|
|
136
|
+
): Site | null {
|
|
75
137
|
if (stack == null) {
|
|
76
138
|
return null;
|
|
77
139
|
}
|
|
@@ -87,6 +149,36 @@ export function firstUserSite(stack: string | null | void, skipInternal: boolean
|
|
|
87
149
|
return null;
|
|
88
150
|
}
|
|
89
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The first position in `stack` that is in `file`, or `null`.
|
|
154
|
+
*
|
|
155
|
+
* The reporter draws a failure's position as `path:line:column`, and it takes
|
|
156
|
+
* the path from the file it asked the worker to run rather than from the
|
|
157
|
+
* frame. So a number lifted from any other file is not a vaguer answer than
|
|
158
|
+
* none — it is a wrong one, naming a line the reader can open and that has
|
|
159
|
+
* nothing to do with what failed. Restricting the search to the file that will
|
|
160
|
+
* be named makes the two halves of that position come from the same place.
|
|
161
|
+
*
|
|
162
|
+
* `null` when the file is on no frame, which is a failure raised from work the
|
|
163
|
+
* case left behind: no line of it is the one that failed, and printing none is
|
|
164
|
+
* the honest answer.
|
|
165
|
+
*/
|
|
166
|
+
export function siteInFile(stack: string | null | void, file: string): Site | null {
|
|
167
|
+
if (stack == null) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
for (const frame of stack.split("\n").slice(1)) {
|
|
171
|
+
if (!frame.includes(file)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const site = frameSite(frame);
|
|
175
|
+
if (site != null) {
|
|
176
|
+
return site;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
90
182
|
/**
|
|
91
183
|
* `stack` with the runner's own frames removed.
|
|
92
184
|
*
|