@uniflowed/test 0.0.0-alpha.1
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 +35 -0
- package/internal/equality.js +322 -0
- package/internal/expect.js +407 -0
- package/internal/frames.js +105 -0
- package/internal/registry.js +243 -0
- package/internal/run.js +314 -0
- package/package.json +23 -0
- package/worker.js +132 -0
package/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/test`: the test API a project writes against.
|
|
4
|
+
//
|
|
5
|
+
// This is the implementation, not a declaration of one. `describe`, `it` and
|
|
6
|
+
// the hooks collect into a tree ([`./internal/registry.js`]); `expect` is a
|
|
7
|
+
// real matcher set ([`./internal/expect.js`]); `./worker.js` is the process
|
|
8
|
+
// `uf test` runs them in. `uf` owns discovery, scheduling across cores, the
|
|
9
|
+
// timings that order a run longest-first, watch invalidation and the terminal
|
|
10
|
+
// report — everything that is faster in Rust — and the host owns executing
|
|
11
|
+
// JavaScript, which is the one thing Rust cannot do.
|
|
12
|
+
//
|
|
13
|
+
// The whole surface is importable from here, so a test file has one import.
|
|
14
|
+
|
|
15
|
+
export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
|
|
16
|
+
export type { Outcome, Result, RunOptions } from "./internal/run.js";
|
|
17
|
+
export type { Site } from "./internal/frames.js";
|
|
18
|
+
export type { SpyCall } from "./internal/expect.js";
|
|
19
|
+
export type { Strictness } from "./internal/equality.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
afterAll,
|
|
23
|
+
afterEach,
|
|
24
|
+
beforeAll,
|
|
25
|
+
beforeEach,
|
|
26
|
+
describe,
|
|
27
|
+
it,
|
|
28
|
+
test,
|
|
29
|
+
} from "./internal/registry.js";
|
|
30
|
+
|
|
31
|
+
export { AssertionError, expect, fn } from "./internal/expect.js";
|
|
32
|
+
|
|
33
|
+
export { DEFAULT_TIMEOUT_MS, NAME_SEPARATOR } from "./internal/run.js";
|
|
34
|
+
|
|
35
|
+
export { equals, render } from "./internal/equality.js";
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Structural comparison, and how a mismatch is described.
|
|
4
|
+
//
|
|
5
|
+
// Two values are compared the way a person means when they write
|
|
6
|
+
// `toEqual`: same shape, same contents, recursively, with the built-in types
|
|
7
|
+
// that have their own idea of equality (`Date`, `RegExp`, `Map`, `Set`,
|
|
8
|
+
// `Error`, typed arrays) compared by what they hold rather than by identity.
|
|
9
|
+
//
|
|
10
|
+
// Three details are deliberate, because getting them wrong is how an
|
|
11
|
+
// assertion library quietly lies:
|
|
12
|
+
//
|
|
13
|
+
// * `NaN` equals `NaN`, and `+0` does not equal `-0`. Both follow `Object.is`,
|
|
14
|
+
// which is what a test author means by "the same number".
|
|
15
|
+
// * Cycles terminate. A pair already being compared is assumed equal while
|
|
16
|
+
// its own comparison is in progress, which is the standard co-inductive
|
|
17
|
+
// reading and the only one that terminates.
|
|
18
|
+
// * `toEqual` ignores `undefined` properties and `toStrictEqual` does not,
|
|
19
|
+
// which is the one place the two matchers differ besides prototypes.
|
|
20
|
+
|
|
21
|
+
/** How strictly two values are compared. */
|
|
22
|
+
export type Strictness = "loose" | "strict";
|
|
23
|
+
|
|
24
|
+
type Pair = {| +left: mixed, +right: mixed |};
|
|
25
|
+
|
|
26
|
+
/** Longest rendering of one value inside a failure message. */
|
|
27
|
+
export const MAX_RENDER_BYTES: number = 4096;
|
|
28
|
+
|
|
29
|
+
/** Deepest structure the renderer descends into before it elides. */
|
|
30
|
+
const MAX_RENDER_DEPTH = 6;
|
|
31
|
+
|
|
32
|
+
/** Most entries of a collection the renderer shows before eliding. */
|
|
33
|
+
const MAX_RENDER_ENTRIES = 32;
|
|
34
|
+
|
|
35
|
+
function isObject(value: mixed): boolean {
|
|
36
|
+
return typeof value === "object" && value !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tag(value: mixed): string {
|
|
40
|
+
return Object.prototype.toString.call(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Own enumerable keys, string and symbol, in a stable order.
|
|
45
|
+
*
|
|
46
|
+
* Insertion order is not stable across two objects built differently, and a
|
|
47
|
+
* comparison that depended on it would report a difference where there is
|
|
48
|
+
* none, so string keys are sorted.
|
|
49
|
+
*/
|
|
50
|
+
function ownKeys(value: interface {}, strictness: Strictness): Array<string | symbol> {
|
|
51
|
+
const strings = Object.keys(value).sort();
|
|
52
|
+
const symbols = Object.getOwnPropertySymbols(value).filter((symbol) =>
|
|
53
|
+
Object.prototype.propertyIsEnumerable.call(value, symbol),
|
|
54
|
+
);
|
|
55
|
+
const keys: Array<string | symbol> = [...strings, ...symbols];
|
|
56
|
+
if (strictness === "strict") {
|
|
57
|
+
return keys;
|
|
58
|
+
}
|
|
59
|
+
// `toEqual` treats an absent property and one set to `undefined` as the
|
|
60
|
+
// same thing, so a key holding `undefined` is not a difference.
|
|
61
|
+
return keys.filter((key) => (value: $FlowFixMe)[key] !== undefined);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameSet(left: Set<mixed>, right: Set<mixed>, seen: Array<Pair>, strictness: Strictness): boolean {
|
|
65
|
+
if (left.size !== right.size) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const remaining = [...right];
|
|
69
|
+
for (const item of left) {
|
|
70
|
+
const at = remaining.findIndex((candidate) => equals(item, candidate, seen, strictness));
|
|
71
|
+
if (at === -1) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
remaining.splice(at, 1);
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameMap(
|
|
80
|
+
left: Map<mixed, mixed>,
|
|
81
|
+
right: Map<mixed, mixed>,
|
|
82
|
+
seen: Array<Pair>,
|
|
83
|
+
strictness: Strictness,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (left.size !== right.size) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const remaining = [...right];
|
|
89
|
+
for (const [key, value] of left) {
|
|
90
|
+
const at = remaining.findIndex(
|
|
91
|
+
([otherKey, otherValue]) =>
|
|
92
|
+
equals(key, otherKey, seen, strictness) && equals(value, otherValue, seen, strictness),
|
|
93
|
+
);
|
|
94
|
+
if (at === -1) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
remaining.splice(at, 1);
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether `left` and `right` are structurally equal.
|
|
104
|
+
*
|
|
105
|
+
* `seen` carries the pairs currently being compared, which is what makes a
|
|
106
|
+
* cyclic structure terminate.
|
|
107
|
+
*/
|
|
108
|
+
export function equals(
|
|
109
|
+
left: mixed,
|
|
110
|
+
right: mixed,
|
|
111
|
+
seen: Array<Pair> = [],
|
|
112
|
+
strictness: Strictness = "loose",
|
|
113
|
+
): boolean {
|
|
114
|
+
if (Object.is(left, right)) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (!isObject(left) || !isObject(right)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
for (const pair of seen) {
|
|
121
|
+
if (pair.left === left && pair.right === right) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const leftTag = tag(left);
|
|
127
|
+
if (leftTag !== tag(right)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (strictness === "strict" && Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const nested = [...seen, { left, right }];
|
|
135
|
+
|
|
136
|
+
if (left instanceof Date && right instanceof Date) {
|
|
137
|
+
return Object.is(left.getTime(), right.getTime());
|
|
138
|
+
}
|
|
139
|
+
if (left instanceof RegExp && right instanceof RegExp) {
|
|
140
|
+
return left.source === right.source && left.flags === right.flags;
|
|
141
|
+
}
|
|
142
|
+
if (left instanceof Error && right instanceof Error) {
|
|
143
|
+
return left.name === right.name && left.message === right.message;
|
|
144
|
+
}
|
|
145
|
+
if (left instanceof Set && right instanceof Set) {
|
|
146
|
+
return sameSet(left, right, nested, strictness);
|
|
147
|
+
}
|
|
148
|
+
if (left instanceof Map && right instanceof Map) {
|
|
149
|
+
return sameMap(left, right, nested, strictness);
|
|
150
|
+
}
|
|
151
|
+
if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) {
|
|
152
|
+
const leftBytes = new Uint8Array(
|
|
153
|
+
(left: $FlowFixMe).buffer,
|
|
154
|
+
(left: $FlowFixMe).byteOffset,
|
|
155
|
+
(left: $FlowFixMe).byteLength,
|
|
156
|
+
);
|
|
157
|
+
const rightBytes = new Uint8Array(
|
|
158
|
+
(right: $FlowFixMe).buffer,
|
|
159
|
+
(right: $FlowFixMe).byteOffset,
|
|
160
|
+
(right: $FlowFixMe).byteLength,
|
|
161
|
+
);
|
|
162
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
for (let index = 0; index < leftBytes.length; index += 1) {
|
|
166
|
+
if (leftBytes[index] !== rightBytes[index]) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(left) !== Array.isArray(right)) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(left) && Array.isArray(right) && left.length !== right.length) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const leftKeys = ownKeys((left: $FlowFixMe), strictness);
|
|
180
|
+
const rightKeys = ownKeys((right: $FlowFixMe), strictness);
|
|
181
|
+
if (leftKeys.length !== rightKeys.length) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
for (const key of leftKeys) {
|
|
185
|
+
if (!Object.prototype.hasOwnProperty.call(right, key)) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (!equals((left: $FlowFixMe)[key], (right: $FlowFixMe)[key], nested, strictness)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether every property `expected` names is present and equal in `received`.
|
|
197
|
+
*
|
|
198
|
+
* The recursive half of `toMatchObject`: extra properties on `received` are
|
|
199
|
+
* fine, missing or different ones are not.
|
|
200
|
+
*/
|
|
201
|
+
export function matchesObject(received: mixed, expected: mixed, seen: Array<Pair> = []): boolean {
|
|
202
|
+
if (!isObject(expected) || !isObject(received)) {
|
|
203
|
+
return equals(received, expected, seen);
|
|
204
|
+
}
|
|
205
|
+
for (const pair of seen) {
|
|
206
|
+
if (pair.left === received && pair.right === expected) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const nested = [...seen, { left: received, right: expected }];
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(expected)) {
|
|
213
|
+
if (!Array.isArray(received) || received.length !== expected.length) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
return expected.every((item, index) => matchesObject(received[index], item, nested));
|
|
217
|
+
}
|
|
218
|
+
for (const key of ownKeys((expected: $FlowFixMe), "loose")) {
|
|
219
|
+
if (!Object.prototype.hasOwnProperty.call(received, key)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (!matchesObject((received: $FlowFixMe)[key], (expected: $FlowFixMe)[key], nested)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function quoteString(value: string): string {
|
|
230
|
+
return JSON.stringify(value) ?? `"${value}"`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A one-line-ish rendering of `value` for a failure message.
|
|
235
|
+
*
|
|
236
|
+
* This is not `JSON.stringify`: it has to show `undefined`, functions,
|
|
237
|
+
* symbols, `NaN`, cycles and class instances, all of which JSON either drops
|
|
238
|
+
* or refuses. Depth, breadth and total size are bounded, because a failure
|
|
239
|
+
* message that scrolls the terminal is a failure message nobody reads.
|
|
240
|
+
*/
|
|
241
|
+
export function render(value: mixed, depth: number = 0, seen: Array<mixed> = []): string {
|
|
242
|
+
const text = renderInner(value, depth, seen);
|
|
243
|
+
return text.length > MAX_RENDER_BYTES ? `${text.slice(0, MAX_RENDER_BYTES)}… (elided)` : text;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderInner(value: mixed, depth: number, seen: Array<mixed>): string {
|
|
247
|
+
if (value === undefined) {
|
|
248
|
+
return "undefined";
|
|
249
|
+
}
|
|
250
|
+
if (value === null) {
|
|
251
|
+
return "null";
|
|
252
|
+
}
|
|
253
|
+
switch (typeof value) {
|
|
254
|
+
case "string":
|
|
255
|
+
return quoteString(value);
|
|
256
|
+
case "number":
|
|
257
|
+
return Object.is(value, -0) ? "-0" : String(value);
|
|
258
|
+
case "bigint":
|
|
259
|
+
return `${String(value)}n`;
|
|
260
|
+
case "boolean":
|
|
261
|
+
return String(value);
|
|
262
|
+
case "symbol":
|
|
263
|
+
return String(value);
|
|
264
|
+
case "function": {
|
|
265
|
+
const name = (value: $FlowFixMe).name;
|
|
266
|
+
return name === "" ? "[Function (anonymous)]" : `[Function ${name}]`;
|
|
267
|
+
}
|
|
268
|
+
default:
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (seen.includes(value)) {
|
|
272
|
+
return "[Circular]";
|
|
273
|
+
}
|
|
274
|
+
if (depth >= MAX_RENDER_DEPTH) {
|
|
275
|
+
return Array.isArray(value) ? "[Array]" : "[Object]";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const nested = [...seen, value];
|
|
279
|
+
const inner = (item: mixed) => renderInner(item, depth + 1, nested);
|
|
280
|
+
|
|
281
|
+
if (value instanceof Date) {
|
|
282
|
+
return `Date(${value.toISOString()})`;
|
|
283
|
+
}
|
|
284
|
+
if (value instanceof RegExp) {
|
|
285
|
+
return String(value);
|
|
286
|
+
}
|
|
287
|
+
if (value instanceof Error) {
|
|
288
|
+
return `${value.name}(${quoteString(value.message)})`;
|
|
289
|
+
}
|
|
290
|
+
if (value instanceof Set) {
|
|
291
|
+
const items = [...value].slice(0, MAX_RENDER_ENTRIES).map(inner);
|
|
292
|
+
const more = value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
|
|
293
|
+
return `Set { ${items.join(", ")}${more} }`;
|
|
294
|
+
}
|
|
295
|
+
if (value instanceof Map) {
|
|
296
|
+
const items = [...value]
|
|
297
|
+
.slice(0, MAX_RENDER_ENTRIES)
|
|
298
|
+
.map(([key, item]) => `${inner(key)} => ${inner(item)}`);
|
|
299
|
+
const more = value.size > MAX_RENDER_ENTRIES ? `, …${value.size - MAX_RENDER_ENTRIES} more` : "";
|
|
300
|
+
return `Map { ${items.join(", ")}${more} }`;
|
|
301
|
+
}
|
|
302
|
+
if (Array.isArray(value)) {
|
|
303
|
+
const items = value.slice(0, MAX_RENDER_ENTRIES).map(inner);
|
|
304
|
+
const more = value.length > MAX_RENDER_ENTRIES ? `, …${value.length - MAX_RENDER_ENTRIES} more` : "";
|
|
305
|
+
return `[${items.join(", ")}${more}]`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const record: $FlowFixMe = value;
|
|
309
|
+
const keys = ownKeys(record, "strict").slice(0, MAX_RENDER_ENTRIES);
|
|
310
|
+
const entries = keys.map((key) => {
|
|
311
|
+
const name = typeof key === "symbol" ? `[${String(key)}]` : key;
|
|
312
|
+
return `${name}: ${inner(record[key])}`;
|
|
313
|
+
});
|
|
314
|
+
const total = ownKeys(record, "strict").length;
|
|
315
|
+
const more = total > MAX_RENDER_ENTRIES ? `, …${total - MAX_RENDER_ENTRIES} more` : "";
|
|
316
|
+
const prototype = Object.getPrototypeOf(record);
|
|
317
|
+
const constructorName =
|
|
318
|
+
prototype != null && prototype.constructor != null && prototype.constructor.name !== "Object"
|
|
319
|
+
? `${prototype.constructor.name} `
|
|
320
|
+
: "";
|
|
321
|
+
return entries.length === 0 ? `${constructorName}{}` : `${constructorName}{ ${entries.join(", ")}${more} }`;
|
|
322
|
+
}
|