1tube 0.3.0 → 0.4.0
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/dist/test/api.d.ts +55 -13
- package/dist/test/api.js +5 -5
- package/dist/test/assert.d.ts +2 -0
- package/dist/test/assert.js +3 -3
- package/dist/test/core.d.ts +19 -0
- package/dist/test/core.js +100 -0
- package/dist/test/expect.d.ts +128 -0
- package/dist/test/expect.js +592 -0
- package/dist/test/node.d.ts +4 -7
- package/dist/test/node.js +26 -16
- package/package.json +1 -1
- package/src/test/api.ts +64 -13
- package/src/test/assert.ts +3 -3
- package/src/test/bun.ts +20 -24
- package/src/test/core.ts +130 -0
- package/src/test/expect.ts +720 -0
- package/src/test/node.ts +31 -31
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic `expect`, mock functions and spies for `1tube/test`.
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript with no runtime imports — the same semantics under every
|
|
5
|
+
* runner. Matchers follow the jest conventions Bun's `bun:test` also uses
|
|
6
|
+
* (`toEqual` ignores `undefined` properties and object types,
|
|
7
|
+
* `toStrictEqual` does not, `toMatchObject` matches subsets, asymmetric
|
|
8
|
+
* matchers like `expect.objectContaining` nest anywhere), so tests written
|
|
9
|
+
* against `bun:test` port by changing the import line.
|
|
10
|
+
*/
|
|
11
|
+
import { AssertionError, format, formatDiff } from "./assert.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Asymmetric matchers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const ASYMMETRIC = Symbol.for("1tube.test.asymmetricMatcher");
|
|
16
|
+
function asymmetric(describe, match) {
|
|
17
|
+
return { [ASYMMETRIC]: true, asymmetricMatch: match, toString: () => describe };
|
|
18
|
+
}
|
|
19
|
+
function isAsymmetric(v) {
|
|
20
|
+
return typeof v === "object" && v !== null && v[ASYMMETRIC] === true;
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Equality
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const tag = (v) => Object.prototype.toString.call(v);
|
|
26
|
+
function ownKeys(o, strict) {
|
|
27
|
+
const keys = [
|
|
28
|
+
...Object.keys(o),
|
|
29
|
+
...Object.getOwnPropertySymbols(o).filter((s) => Object.prototype.propertyIsEnumerable.call(o, s)),
|
|
30
|
+
];
|
|
31
|
+
// Loose equality treats `{ a: undefined }` like `{}`.
|
|
32
|
+
return strict ? keys : keys.filter((k) => o[k] !== undefined);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* jest-style deep equality. `strict` (toStrictEqual) additionally checks
|
|
36
|
+
* constructors, `undefined`-valued keys and array holes.
|
|
37
|
+
*/
|
|
38
|
+
export function deepEquals(a, b, strict = false) {
|
|
39
|
+
const seenA = [];
|
|
40
|
+
const seenB = [];
|
|
41
|
+
const eq = (x, y) => {
|
|
42
|
+
if (isAsymmetric(x))
|
|
43
|
+
return x.asymmetricMatch(y);
|
|
44
|
+
if (isAsymmetric(y))
|
|
45
|
+
return y.asymmetricMatch(x);
|
|
46
|
+
if (Object.is(x, y))
|
|
47
|
+
return true;
|
|
48
|
+
if (typeof x !== "object" || typeof y !== "object" || x === null || y === null)
|
|
49
|
+
return false;
|
|
50
|
+
const t = tag(x);
|
|
51
|
+
if (t !== tag(y))
|
|
52
|
+
return false;
|
|
53
|
+
if (strict && Object.getPrototypeOf(x) !== Object.getPrototypeOf(y))
|
|
54
|
+
return false;
|
|
55
|
+
// Cycles: a pair already being compared is assumed equal.
|
|
56
|
+
const i = seenA.indexOf(x);
|
|
57
|
+
if (i !== -1 && seenB[i] === y)
|
|
58
|
+
return true;
|
|
59
|
+
seenA.push(x);
|
|
60
|
+
seenB.push(y);
|
|
61
|
+
try {
|
|
62
|
+
if (x instanceof Date)
|
|
63
|
+
return Object.is(x.getTime(), y.getTime());
|
|
64
|
+
if (x instanceof RegExp)
|
|
65
|
+
return x.source === y.source && x.flags === y.flags;
|
|
66
|
+
if (typeof URL !== "undefined" && x instanceof URL)
|
|
67
|
+
return x.href === y.href;
|
|
68
|
+
if (x instanceof Number || x instanceof String || x instanceof Boolean) {
|
|
69
|
+
return Object.is(x.valueOf(), y.valueOf());
|
|
70
|
+
}
|
|
71
|
+
if (x instanceof Error) {
|
|
72
|
+
const ye = y;
|
|
73
|
+
if (x.name !== ye.name || x.message !== ye.message)
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (x instanceof ArrayBuffer)
|
|
77
|
+
return eq(new Uint8Array(x), new Uint8Array(y));
|
|
78
|
+
if (ArrayBuffer.isView(x) && !(x instanceof DataView)) {
|
|
79
|
+
const xa = x;
|
|
80
|
+
const ya = y;
|
|
81
|
+
if (xa.length !== ya.length)
|
|
82
|
+
return false;
|
|
83
|
+
for (let k = 0; k < xa.length; k++)
|
|
84
|
+
if (!Object.is(xa[k], ya[k]))
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (x instanceof Map) {
|
|
89
|
+
const ym = y;
|
|
90
|
+
if (x.size !== ym.size)
|
|
91
|
+
return false;
|
|
92
|
+
outer: for (const [k, v] of x) {
|
|
93
|
+
if (ym.has(k)) {
|
|
94
|
+
if (!eq(v, ym.get(k)))
|
|
95
|
+
return false;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const [k2, v2] of ym)
|
|
99
|
+
if (eq(k, k2) && eq(v, v2))
|
|
100
|
+
continue outer;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (x instanceof Set) {
|
|
106
|
+
const ys = y;
|
|
107
|
+
if (x.size !== ys.size)
|
|
108
|
+
return false;
|
|
109
|
+
outer: for (const v of x) {
|
|
110
|
+
if (ys.has(v))
|
|
111
|
+
continue;
|
|
112
|
+
for (const v2 of ys)
|
|
113
|
+
if (eq(v, v2))
|
|
114
|
+
continue outer;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(x)) {
|
|
120
|
+
const ya = y;
|
|
121
|
+
if (x.length !== ya.length)
|
|
122
|
+
return false;
|
|
123
|
+
for (let k = 0; k < x.length; k++) {
|
|
124
|
+
if (strict && (k in x) !== (k in ya))
|
|
125
|
+
return false;
|
|
126
|
+
if (!eq(x[k], ya[k]))
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
// Extra non-index properties on arrays still count.
|
|
130
|
+
}
|
|
131
|
+
const xk = ownKeys(x, strict).filter((k) => !(Array.isArray(x) && typeof k === "string" && /^\d+$/.test(k)));
|
|
132
|
+
const yk = ownKeys(y, strict).filter((k) => !(Array.isArray(y) && typeof k === "string" && /^\d+$/.test(k)));
|
|
133
|
+
if (xk.length !== yk.length)
|
|
134
|
+
return false;
|
|
135
|
+
const rec = (o, k) => o[k];
|
|
136
|
+
for (const k of xk) {
|
|
137
|
+
if (strict && !Object.prototype.hasOwnProperty.call(y, k))
|
|
138
|
+
return false;
|
|
139
|
+
if (!eq(rec(x, k), rec(y, k)))
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
seenA.pop();
|
|
146
|
+
seenB.pop();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return eq(a, b);
|
|
150
|
+
}
|
|
151
|
+
/** `actual` contains `expected` recursively (toMatchObject semantics). */
|
|
152
|
+
function subsetEquals(actual, expected) {
|
|
153
|
+
if (isAsymmetric(expected))
|
|
154
|
+
return expected.asymmetricMatch(actual);
|
|
155
|
+
if (Array.isArray(expected)) {
|
|
156
|
+
return Array.isArray(actual) && actual.length === expected.length &&
|
|
157
|
+
expected.every((e, i) => subsetEquals(actual[i], e));
|
|
158
|
+
}
|
|
159
|
+
if (typeof expected === "object" && expected !== null && tag(expected) === "[object Object]") {
|
|
160
|
+
if (typeof actual !== "object" || actual === null)
|
|
161
|
+
return false;
|
|
162
|
+
return ownKeys(expected, true).every((k) => k in actual &&
|
|
163
|
+
subsetEquals(actual[k], expected[k]));
|
|
164
|
+
}
|
|
165
|
+
return deepEquals(actual, expected);
|
|
166
|
+
}
|
|
167
|
+
const MOCK = Symbol.for("1tube.test.mock");
|
|
168
|
+
const spies = new Set();
|
|
169
|
+
/** Create a mock function, optionally wrapping an implementation. */
|
|
170
|
+
export function createMock(impl, restore) {
|
|
171
|
+
let defaultImpl = impl;
|
|
172
|
+
const once = [];
|
|
173
|
+
let name = impl?.name || "mock";
|
|
174
|
+
const state = { calls: [], results: [], instances: [], lastCall: undefined };
|
|
175
|
+
const fn = function (...args) {
|
|
176
|
+
state.calls.push(args);
|
|
177
|
+
state.lastCall = args;
|
|
178
|
+
state.instances.push(this);
|
|
179
|
+
const result = { type: "incomplete", value: undefined };
|
|
180
|
+
state.results.push(result);
|
|
181
|
+
const current = once.shift() ?? defaultImpl;
|
|
182
|
+
try {
|
|
183
|
+
const value = current ? (new.target ? Reflect.construct(current, args, new.target) : current.apply(this, args)) : undefined;
|
|
184
|
+
Object.assign(result, { type: "return", value });
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
Object.assign(result, { type: "throw", value: err });
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const self = fn;
|
|
193
|
+
Object.defineProperty(fn, "mock", { value: state, enumerable: false });
|
|
194
|
+
Object.defineProperty(fn, MOCK, { value: true });
|
|
195
|
+
Object.assign(self, {
|
|
196
|
+
mockImplementation: (f) => ((defaultImpl = f), fn),
|
|
197
|
+
mockImplementationOnce: (f) => (once.push(f), fn),
|
|
198
|
+
mockReturnValue: (v) => ((defaultImpl = () => v), fn),
|
|
199
|
+
mockReturnValueOnce: (v) => (once.push(() => v), fn),
|
|
200
|
+
mockResolvedValue: (v) => ((defaultImpl = () => Promise.resolve(v)), fn),
|
|
201
|
+
mockResolvedValueOnce: (v) => (once.push(() => Promise.resolve(v)), fn),
|
|
202
|
+
mockRejectedValue: (v) => ((defaultImpl = () => Promise.reject(v)), fn),
|
|
203
|
+
mockRejectedValueOnce: (v) => (once.push(() => Promise.reject(v)), fn),
|
|
204
|
+
mockName: (n) => ((name = n), fn),
|
|
205
|
+
getMockName: () => name,
|
|
206
|
+
mockClear: () => {
|
|
207
|
+
state.calls.length = 0;
|
|
208
|
+
state.results.length = 0;
|
|
209
|
+
state.instances.length = 0;
|
|
210
|
+
state.lastCall = undefined;
|
|
211
|
+
return fn;
|
|
212
|
+
},
|
|
213
|
+
mockReset: () => {
|
|
214
|
+
self.mockClear();
|
|
215
|
+
defaultImpl = undefined;
|
|
216
|
+
once.length = 0;
|
|
217
|
+
return fn;
|
|
218
|
+
},
|
|
219
|
+
mockRestore: () => {
|
|
220
|
+
self.mockReset();
|
|
221
|
+
restore?.();
|
|
222
|
+
spies.delete(fn);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
return fn;
|
|
226
|
+
}
|
|
227
|
+
export function isMock(v) {
|
|
228
|
+
return typeof v === "function" && v[MOCK] === true;
|
|
229
|
+
}
|
|
230
|
+
/** Replace `obj[key]` with a mock that calls through to the original. */
|
|
231
|
+
export function spyOn(obj, key) {
|
|
232
|
+
const original = obj[key];
|
|
233
|
+
if (typeof original !== "function")
|
|
234
|
+
throw new TypeError(`spyOn: ${String(key)} is not a function`);
|
|
235
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(obj, key);
|
|
236
|
+
const spy = createMock(original, () => {
|
|
237
|
+
if (hadOwn)
|
|
238
|
+
obj[key] = original;
|
|
239
|
+
else
|
|
240
|
+
delete obj[key];
|
|
241
|
+
});
|
|
242
|
+
obj[key] = spy;
|
|
243
|
+
spies.add(spy);
|
|
244
|
+
return spy;
|
|
245
|
+
}
|
|
246
|
+
/** Restore every active spy (`mock.restore()`). */
|
|
247
|
+
export function restoreAllMocks() {
|
|
248
|
+
for (const spy of [...spies])
|
|
249
|
+
spy.mockRestore();
|
|
250
|
+
}
|
|
251
|
+
const show = (v) => (isAsymmetric(v) ? v.toString() : format(v));
|
|
252
|
+
const closeTo = (actual, expected, digits) => (actual === Infinity && expected === Infinity) || (actual === -Infinity && expected === -Infinity) ||
|
|
253
|
+
Math.abs(expected - actual) < 10 ** -digits / 2;
|
|
254
|
+
function getPath(obj, path) {
|
|
255
|
+
const parts = typeof path === "string"
|
|
256
|
+
? path.replace(/\[(\d+)\]/g, ".$1").split(".").filter((p) => p !== "")
|
|
257
|
+
: [...path];
|
|
258
|
+
let cur = obj;
|
|
259
|
+
for (const p of parts) {
|
|
260
|
+
if (cur === null || cur === undefined)
|
|
261
|
+
return { found: false, value: undefined };
|
|
262
|
+
const box = Object(cur);
|
|
263
|
+
if (!(p in box))
|
|
264
|
+
return { found: false, value: undefined };
|
|
265
|
+
cur = box[p];
|
|
266
|
+
}
|
|
267
|
+
return { found: true, value: cur };
|
|
268
|
+
}
|
|
269
|
+
function throwMatches(err, expected) {
|
|
270
|
+
if (expected === undefined)
|
|
271
|
+
return true;
|
|
272
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
273
|
+
if (typeof expected === "string")
|
|
274
|
+
return message.includes(expected);
|
|
275
|
+
if (expected instanceof RegExp)
|
|
276
|
+
return expected.test(message);
|
|
277
|
+
if (isAsymmetric(expected))
|
|
278
|
+
return expected.asymmetricMatch(err);
|
|
279
|
+
if (expected instanceof Error)
|
|
280
|
+
return message === expected.message;
|
|
281
|
+
if (typeof expected === "function")
|
|
282
|
+
return err instanceof expected;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
function requireMock(v, matcher) {
|
|
286
|
+
if (!isMock(v))
|
|
287
|
+
throw new AssertionError(`${matcher}: received value must be a mock function (from mock() or spyOn())`);
|
|
288
|
+
return v;
|
|
289
|
+
}
|
|
290
|
+
const lengthOf = (v) => v !== null && v !== undefined && typeof v.length === "number"
|
|
291
|
+
? v.length
|
|
292
|
+
: undefined;
|
|
293
|
+
/** Evaluate one matcher against `actual`. `thrown` carries `.rejects`. */
|
|
294
|
+
function evaluate(name, actual, args) {
|
|
295
|
+
const [expected] = args;
|
|
296
|
+
const recv = `Received: ${show(actual)}`;
|
|
297
|
+
const simple = (pass, what) => ({
|
|
298
|
+
pass,
|
|
299
|
+
message: (neg) => `expect(received).${neg ? "not." : ""}${name}(${args.map(show).join(", ")})\n\n${what ? `${what}\n` : ""}${recv}`,
|
|
300
|
+
});
|
|
301
|
+
switch (name) {
|
|
302
|
+
case "toBe": {
|
|
303
|
+
const pass = Object.is(actual, expected);
|
|
304
|
+
return {
|
|
305
|
+
pass,
|
|
306
|
+
message: (neg) => neg
|
|
307
|
+
? `expect(received).not.toBe(expected)\n\nExpected: not ${show(expected)}`
|
|
308
|
+
: deepEquals(actual, expected, true)
|
|
309
|
+
? `expect(received).toBe(expected)\n\nValues have the same structure but are not the same reference\n\nReceived: ${show(actual)}`
|
|
310
|
+
: `expect(received).toBe(expected)\n\nExpected: ${show(expected)}\n${recv}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
case "toEqual":
|
|
314
|
+
case "toStrictEqual": {
|
|
315
|
+
const pass = deepEquals(actual, expected, name === "toStrictEqual");
|
|
316
|
+
return {
|
|
317
|
+
pass,
|
|
318
|
+
message: (neg) => neg
|
|
319
|
+
? `expect(received).not.${name}(expected)\n\nExpected: not ${show(expected)}`
|
|
320
|
+
: `expect(received).${name}(expected)\n\n- Received\n+ Expected\n\n${formatDiff(actual, expected)}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
case "toMatchObject": {
|
|
324
|
+
const pass = typeof actual === "object" && actual !== null && subsetEquals(actual, expected);
|
|
325
|
+
return {
|
|
326
|
+
pass,
|
|
327
|
+
message: (neg) => neg
|
|
328
|
+
? `expect(received).not.toMatchObject(expected)\n\nExpected: not ${show(expected)}\n${recv}`
|
|
329
|
+
: `expect(received).toMatchObject(expected)\n\n- Received\n+ Expected\n\n${formatDiff(actual, expected)}`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
case "toBeNull":
|
|
333
|
+
return simple(actual === null, "");
|
|
334
|
+
case "toBeUndefined":
|
|
335
|
+
return simple(actual === undefined, "");
|
|
336
|
+
case "toBeDefined":
|
|
337
|
+
return simple(actual !== undefined, "");
|
|
338
|
+
case "toBeTruthy":
|
|
339
|
+
return simple(!!actual, "");
|
|
340
|
+
case "toBeFalsy":
|
|
341
|
+
return simple(!actual, "");
|
|
342
|
+
case "toBeNaN":
|
|
343
|
+
return simple(typeof actual === "number" && Number.isNaN(actual), "");
|
|
344
|
+
case "toBeTypeOf":
|
|
345
|
+
return simple(typeof actual === expected, `Received type: ${typeof actual}`);
|
|
346
|
+
case "toBeInstanceOf":
|
|
347
|
+
return simple(typeof expected === "function" && actual instanceof expected, `Expected constructor: ${expected?.name ?? String(expected)}`);
|
|
348
|
+
case "toBeGreaterThan":
|
|
349
|
+
return simple(actual > expected, "");
|
|
350
|
+
case "toBeGreaterThanOrEqual":
|
|
351
|
+
return simple(actual >= expected, "");
|
|
352
|
+
case "toBeLessThan":
|
|
353
|
+
return simple(actual < expected, "");
|
|
354
|
+
case "toBeLessThanOrEqual":
|
|
355
|
+
return simple(actual <= expected, "");
|
|
356
|
+
case "toBeCloseTo": {
|
|
357
|
+
const digits = args[1] ?? 2;
|
|
358
|
+
return simple(typeof actual === "number" && closeTo(actual, expected, digits), `Expected precision: ${digits}\nExpected difference: < ${10 ** -digits / 2}\nReceived difference: ${Math.abs(expected - actual)}`);
|
|
359
|
+
}
|
|
360
|
+
case "toBeOneOf":
|
|
361
|
+
return simple(expected.some((v) => deepEquals(actual, v)), "");
|
|
362
|
+
case "toContain": {
|
|
363
|
+
let pass = false;
|
|
364
|
+
if (typeof actual === "string")
|
|
365
|
+
pass = actual.includes(String(expected));
|
|
366
|
+
else if (actual && typeof actual[Symbol.iterator] === "function") {
|
|
367
|
+
for (const item of actual) {
|
|
368
|
+
if (item === expected || Object.is(item, expected)) {
|
|
369
|
+
pass = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return simple(pass, "");
|
|
375
|
+
}
|
|
376
|
+
case "toContainEqual": {
|
|
377
|
+
let pass = false;
|
|
378
|
+
if (actual && typeof actual[Symbol.iterator] === "function") {
|
|
379
|
+
for (const item of actual) {
|
|
380
|
+
if (deepEquals(item, expected)) {
|
|
381
|
+
pass = true;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return simple(pass, "");
|
|
387
|
+
}
|
|
388
|
+
case "toInclude":
|
|
389
|
+
return simple(typeof actual === "string" && actual.includes(expected), "");
|
|
390
|
+
case "toStartWith":
|
|
391
|
+
return simple(typeof actual === "string" && actual.startsWith(expected), "");
|
|
392
|
+
case "toEndWith":
|
|
393
|
+
return simple(typeof actual === "string" && actual.endsWith(expected), "");
|
|
394
|
+
case "toMatch":
|
|
395
|
+
return simple(typeof actual === "string" &&
|
|
396
|
+
(typeof expected === "string" ? actual.includes(expected) : (expected.lastIndex = 0, expected.test(actual))), "");
|
|
397
|
+
case "toHaveLength": {
|
|
398
|
+
const len = lengthOf(actual);
|
|
399
|
+
return simple(len === expected, `Received length: ${len}`);
|
|
400
|
+
}
|
|
401
|
+
case "toHaveProperty": {
|
|
402
|
+
const { found, value } = getPath(actual, expected);
|
|
403
|
+
const pass = found && (args.length < 2 || deepEquals(value, args[1]));
|
|
404
|
+
return simple(pass, found ? `Received value: ${show(value)}` : "Property not found");
|
|
405
|
+
}
|
|
406
|
+
case "toBeEmpty": {
|
|
407
|
+
let pass = false;
|
|
408
|
+
if (typeof actual === "string" || Array.isArray(actual))
|
|
409
|
+
pass = actual.length === 0;
|
|
410
|
+
else if (actual instanceof Map || actual instanceof Set)
|
|
411
|
+
pass = actual.size === 0;
|
|
412
|
+
else if (actual && typeof actual === "object")
|
|
413
|
+
pass = Object.keys(actual).length === 0;
|
|
414
|
+
return simple(pass, "");
|
|
415
|
+
}
|
|
416
|
+
case "toHaveBeenCalled": {
|
|
417
|
+
const m = requireMock(actual, name);
|
|
418
|
+
return simple(m.mock.calls.length > 0, `Number of calls: ${m.mock.calls.length}`);
|
|
419
|
+
}
|
|
420
|
+
case "toHaveBeenCalledTimes": {
|
|
421
|
+
const m = requireMock(actual, name);
|
|
422
|
+
return simple(m.mock.calls.length === expected, `Number of calls: ${m.mock.calls.length}`);
|
|
423
|
+
}
|
|
424
|
+
case "toHaveBeenCalledWith": {
|
|
425
|
+
const m = requireMock(actual, name);
|
|
426
|
+
return simple(m.mock.calls.some((c) => deepEquals(c, args)), `Calls:\n${m.mock.calls.map((c) => ` ${show(c)}`).join("\n") || " (none)"}`);
|
|
427
|
+
}
|
|
428
|
+
case "toHaveBeenLastCalledWith": {
|
|
429
|
+
const m = requireMock(actual, name);
|
|
430
|
+
return simple(m.mock.lastCall !== undefined && deepEquals(m.mock.lastCall, args), `Last call: ${show(m.mock.lastCall)}`);
|
|
431
|
+
}
|
|
432
|
+
case "toHaveBeenNthCalledWith": {
|
|
433
|
+
const m = requireMock(actual, name);
|
|
434
|
+
const call = m.mock.calls[expected - 1];
|
|
435
|
+
return simple(call !== undefined && deepEquals(call, args.slice(1)), `Call ${expected}: ${show(call)}`);
|
|
436
|
+
}
|
|
437
|
+
case "toHaveReturned": {
|
|
438
|
+
const m = requireMock(actual, name);
|
|
439
|
+
return simple(m.mock.results.some((r) => r.type === "return"), "");
|
|
440
|
+
}
|
|
441
|
+
case "toHaveReturnedTimes": {
|
|
442
|
+
const m = requireMock(actual, name);
|
|
443
|
+
return simple(m.mock.results.filter((r) => r.type === "return").length === expected, "");
|
|
444
|
+
}
|
|
445
|
+
case "toHaveReturnedWith": {
|
|
446
|
+
const m = requireMock(actual, name);
|
|
447
|
+
return simple(m.mock.results.some((r) => r.type === "return" && deepEquals(r.value, expected)), "");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
throw new TypeError(`expect: unknown matcher ${name}`);
|
|
451
|
+
}
|
|
452
|
+
const MATCHERS = [
|
|
453
|
+
"toBe", "toEqual", "toStrictEqual", "toMatchObject", "toBeNull", "toBeUndefined", "toBeDefined",
|
|
454
|
+
"toBeTruthy", "toBeFalsy", "toBeNaN", "toBeTypeOf", "toBeInstanceOf", "toBeGreaterThan",
|
|
455
|
+
"toBeGreaterThanOrEqual", "toBeLessThan", "toBeLessThanOrEqual", "toBeCloseTo", "toBeOneOf",
|
|
456
|
+
"toContain", "toContainEqual", "toInclude", "toStartWith", "toEndWith", "toMatch", "toHaveLength",
|
|
457
|
+
"toHaveProperty", "toBeEmpty", "toHaveBeenCalled", "toHaveBeenCalledTimes", "toHaveBeenCalledWith",
|
|
458
|
+
"toHaveBeenLastCalledWith", "toHaveBeenNthCalledWith", "toHaveReturned", "toHaveReturnedTimes",
|
|
459
|
+
"toHaveReturnedWith",
|
|
460
|
+
];
|
|
461
|
+
function fail(outcome, negated, message) {
|
|
462
|
+
const text = outcome.message(negated);
|
|
463
|
+
throw new AssertionError(message ? `${message}\n\n${text}` : text);
|
|
464
|
+
}
|
|
465
|
+
/** Matchers for a synchronous value, or (mode) for a promise's settlement. */
|
|
466
|
+
function matchers(actual, negated, mode, message) {
|
|
467
|
+
const out = {};
|
|
468
|
+
const check = (name, value, args) => {
|
|
469
|
+
const outcome = evaluate(name, value, args);
|
|
470
|
+
if (outcome.pass === negated)
|
|
471
|
+
fail(outcome, negated, message);
|
|
472
|
+
};
|
|
473
|
+
const names = mode === "sync" ? MATCHERS : [...MATCHERS, "toThrow", "toThrowError"];
|
|
474
|
+
for (const name of names) {
|
|
475
|
+
out[name] = (...args) => {
|
|
476
|
+
if (mode === "sync")
|
|
477
|
+
return check(name, actual, args);
|
|
478
|
+
return (async () => {
|
|
479
|
+
let fulfilled;
|
|
480
|
+
let value;
|
|
481
|
+
try {
|
|
482
|
+
value = await (typeof actual === "function" ? actual() : actual);
|
|
483
|
+
fulfilled = true;
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
value = err;
|
|
487
|
+
fulfilled = false;
|
|
488
|
+
}
|
|
489
|
+
if (mode === "rejects" && fulfilled) {
|
|
490
|
+
throw new AssertionError(`expect(received).rejects.${name}()\n\nReceived promise resolved instead of rejected\nResolved to value: ${show(value)}`);
|
|
491
|
+
}
|
|
492
|
+
if (mode === "resolves" && !fulfilled) {
|
|
493
|
+
throw new AssertionError(`expect(received).resolves.${name}()\n\nReceived promise rejected instead of resolved\nRejected to value: ${show(value)}`);
|
|
494
|
+
}
|
|
495
|
+
// `.rejects.toThrow(x)` matches the reason itself.
|
|
496
|
+
if (mode === "rejects" && (name === "toThrow" || name === "toThrowError")) {
|
|
497
|
+
const pass = throwMatches(value, args[0]);
|
|
498
|
+
if (pass === negated) {
|
|
499
|
+
fail({ pass, message: (neg) => `expect(received).rejects.${neg ? "not." : ""}${name}(${args.map(show).join(", ")})\n\nReceived: ${show(value)}` }, negated, message);
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (name === "toThrow" || name === "toThrowError") {
|
|
504
|
+
throw new AssertionError(`expect(received).resolves.${name}() is not supported; use .rejects.${name}()`);
|
|
505
|
+
}
|
|
506
|
+
check(name, value, args);
|
|
507
|
+
})();
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
for (const name of ["toThrow", "toThrowError"]) {
|
|
511
|
+
if (mode !== "sync")
|
|
512
|
+
continue;
|
|
513
|
+
out[name] = (expected) => {
|
|
514
|
+
if (typeof actual !== "function")
|
|
515
|
+
throw new AssertionError(`expect(received).${name}()\n\nReceived value must be a function`);
|
|
516
|
+
let threw = false;
|
|
517
|
+
let error;
|
|
518
|
+
try {
|
|
519
|
+
actual();
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
threw = true;
|
|
523
|
+
error = err;
|
|
524
|
+
}
|
|
525
|
+
const pass = threw && throwMatches(error, expected);
|
|
526
|
+
if (pass === negated) {
|
|
527
|
+
fail({
|
|
528
|
+
pass,
|
|
529
|
+
message: (neg) => `expect(received).${neg ? "not." : ""}${name}(${expected === undefined ? "" : show(expected)})\n\n${threw ? `Thrown: ${show(error)}` : "Received function did not throw"}`,
|
|
530
|
+
}, negated, message);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
Object.defineProperty(out, "not", { get: () => matchers(actual, !negated, mode, message) });
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
export const expect = Object.assign((actual, message) => {
|
|
538
|
+
const base = matchers(actual, false, "sync", message);
|
|
539
|
+
Object.defineProperty(base, "resolves", { get: () => matchers(actual, false, "resolves", message) });
|
|
540
|
+
Object.defineProperty(base, "rejects", { get: () => matchers(actual, false, "rejects", message) });
|
|
541
|
+
return base;
|
|
542
|
+
}, {
|
|
543
|
+
anything: () => asymmetric("Anything", (o) => o !== null && o !== undefined),
|
|
544
|
+
any: (ctor) => asymmetric(`Any<${ctor?.name}>`, (o) => {
|
|
545
|
+
if (ctor === String)
|
|
546
|
+
return typeof o === "string" || o instanceof String;
|
|
547
|
+
if (ctor === Number)
|
|
548
|
+
return typeof o === "number" || o instanceof Number;
|
|
549
|
+
if (ctor === Boolean)
|
|
550
|
+
return typeof o === "boolean" || o instanceof Boolean;
|
|
551
|
+
if (ctor === BigInt)
|
|
552
|
+
return typeof o === "bigint";
|
|
553
|
+
if (ctor === Symbol)
|
|
554
|
+
return typeof o === "symbol";
|
|
555
|
+
if (ctor === Function)
|
|
556
|
+
return typeof o === "function";
|
|
557
|
+
if (ctor === Object)
|
|
558
|
+
return typeof o === "object" && o !== null;
|
|
559
|
+
return typeof ctor === "function" && o instanceof ctor;
|
|
560
|
+
}),
|
|
561
|
+
objectContaining: (obj) => objectContaining(obj, false),
|
|
562
|
+
arrayContaining: (items) => arrayContaining(items, false),
|
|
563
|
+
stringContaining: (s) => stringContaining(s, false),
|
|
564
|
+
stringMatching: (p) => stringMatching(p, false),
|
|
565
|
+
closeTo: (n, digits = 2) => asymmetric(`NumberCloseTo ${n} (${digits} digits)`, (o) => typeof o === "number" && closeTo(o, n, digits)),
|
|
566
|
+
not: {
|
|
567
|
+
objectContaining: (obj) => objectContaining(obj, true),
|
|
568
|
+
arrayContaining: (items) => arrayContaining(items, true),
|
|
569
|
+
stringContaining: (s) => stringContaining(s, true),
|
|
570
|
+
stringMatching: (p) => stringMatching(p, true),
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
function objectContaining(obj, invert) {
|
|
574
|
+
return asymmetric(`${invert ? "Not" : ""}ObjectContaining ${format(obj)}`, (o) => {
|
|
575
|
+
const pass = typeof o === "object" && o !== null &&
|
|
576
|
+
ownKeys(obj, true).every((k) => k in o && deepEquals(o[k], obj[k]));
|
|
577
|
+
return pass !== invert;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function arrayContaining(items, invert) {
|
|
581
|
+
return asymmetric(`${invert ? "Not" : ""}ArrayContaining ${format(items)}`, (o) => {
|
|
582
|
+
const pass = Array.isArray(o) && items.every((i) => o.some((x) => deepEquals(x, i)));
|
|
583
|
+
return pass !== invert;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function stringContaining(s, invert) {
|
|
587
|
+
return asymmetric(`${invert ? "Not" : ""}StringContaining ${JSON.stringify(s)}`, (o) => (typeof o === "string" && o.includes(s)) !== invert);
|
|
588
|
+
}
|
|
589
|
+
function stringMatching(p, invert) {
|
|
590
|
+
const re = typeof p === "string" ? new RegExp(p) : p;
|
|
591
|
+
return asymmetric(`${invert ? "Not" : ""}StringMatching ${re}`, (o) => (typeof o === "string" && ((re.lastIndex = 0), re.test(o))) !== invert);
|
|
592
|
+
}
|
package/dist/test/node.d.ts
CHANGED
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
* standard `node:test` runner, which Node runs natively and other runtimes
|
|
4
4
|
* implement for compatibility.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
6
|
+
import type { TestRuntime } from "./api.ts";
|
|
7
7
|
export * from "./assert.ts";
|
|
8
|
+
export { createMock, deepEquals, expect, isMock, spyOn } from "./expect.ts";
|
|
8
9
|
export type * from "./api.ts";
|
|
10
|
+
export type { Assertion, AsymmetricMatcher, Expect, Matchers, Mock, MockResult, MockState } from "./expect.ts";
|
|
9
11
|
export declare const runtime: TestRuntime;
|
|
10
|
-
export declare const test: TestFunction;
|
|
11
|
-
export declare const describe: DescribeFunction;
|
|
12
|
-
export declare const beforeAll: Hook;
|
|
13
|
-
export declare const afterAll: Hook;
|
|
14
|
-
export declare const beforeEach: Hook;
|
|
15
|
-
export declare const afterEach: Hook;
|
|
12
|
+
export declare const test: import("./api.ts").TestFunction, it: import("./api.ts").TestFunction, describe: import("./api.ts").DescribeFunction, beforeAll: import("./api.ts").Hook, afterAll: import("./api.ts").Hook, beforeEach: import("./api.ts").Hook, afterEach: import("./api.ts").Hook, mock: import("./api.ts").MockFunction;
|
package/dist/test/node.js
CHANGED
|
@@ -4,26 +4,36 @@
|
|
|
4
4
|
* implement for compatibility.
|
|
5
5
|
*/
|
|
6
6
|
import * as node from "node:test";
|
|
7
|
+
import { createTestApi } from "./core.js";
|
|
7
8
|
export * from "./assert.js";
|
|
9
|
+
export { createMock, deepEquals, expect, isMock, spyOn } from "./expect.js";
|
|
8
10
|
export const runtime = "node";
|
|
9
11
|
// node:test passes a context (and a `done` callback to 2-arity functions);
|
|
10
12
|
// the 1tube contract has neither, so wrap to keep the arity at zero.
|
|
11
|
-
const
|
|
13
|
+
const wrap = (fn) => async () => {
|
|
12
14
|
await fn();
|
|
13
|
-
}
|
|
14
|
-
export const test
|
|
15
|
-
|
|
16
|
-
only: (name, fn, options = {}) => void register(name, fn, { ...options, only: true }),
|
|
15
|
+
};
|
|
16
|
+
export const { test, it, describe, beforeAll, afterAll, beforeEach, afterEach, mock } = createTestApi({
|
|
17
|
+
test: (name, fn, { timeout, skip, only }) => void node.test(name, { timeout, skip, only }, wrap(fn)),
|
|
17
18
|
todo: (name) => void node.test(name, { todo: true }),
|
|
19
|
+
describe: (name, fn, mode) => void node.describe(name, { skip: mode === "skip", only: mode === "only" }, fn),
|
|
20
|
+
beforeAll: (fn) => node.before(wrap(fn)),
|
|
21
|
+
afterAll: (fn) => node.after(wrap(fn)),
|
|
22
|
+
beforeEach: (fn) => node.beforeEach(wrap(fn)),
|
|
23
|
+
afterEach: (fn) => node.afterEach(wrap(fn)),
|
|
24
|
+
mockModule: (specifier, factory) => {
|
|
25
|
+
const moduleMock = node.mock.module;
|
|
26
|
+
if (typeof moduleMock !== "function") {
|
|
27
|
+
throw new Error("mock.module on node:test needs `node --test --experimental-test-module-mocks`");
|
|
28
|
+
}
|
|
29
|
+
const exports = factory();
|
|
30
|
+
if (exports && typeof exports.then === "function") {
|
|
31
|
+
throw new Error("mock.module on node:test needs a synchronous factory");
|
|
32
|
+
}
|
|
33
|
+
const { default: defaultExport, ...namedExports } = exports;
|
|
34
|
+
moduleMock.call(node.mock, specifier, {
|
|
35
|
+
namedExports,
|
|
36
|
+
...("default" in exports ? { defaultExport } : {}),
|
|
37
|
+
});
|
|
38
|
+
},
|
|
18
39
|
});
|
|
19
|
-
export const describe = Object.assign((name, fn) => void node.describe(name, fn), {
|
|
20
|
-
skip: (name, fn) => void node.describe(name, { skip: true }, fn),
|
|
21
|
-
only: (name, fn) => void node.describe(name, { only: true }, fn),
|
|
22
|
-
});
|
|
23
|
-
const hook = (register) => (fn) => register(async () => {
|
|
24
|
-
await fn();
|
|
25
|
-
});
|
|
26
|
-
export const beforeAll = hook((fn) => node.before(fn));
|
|
27
|
-
export const afterAll = hook((fn) => node.after(fn));
|
|
28
|
-
export const beforeEach = hook((fn) => node.beforeEach(fn));
|
|
29
|
-
export const afterEach = hook((fn) => node.afterEach(fn));
|