@uniflowed/test 0.2.0 → 0.3.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/index.js +1 -1
- package/internal/equality.js +9 -6
- package/internal/expect.js +12 -14
- package/internal/modules.js +16 -16
- package/internal/namespace.js +9 -4
- package/internal/snapshot.js +2 -2
- package/internal/spy.js +114 -27
- package/internal/timers.js +15 -11
- package/package.json +2 -2
- package/worker.js +96 -6
package/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
|
|
|
26
26
|
export type { Uft } from "./internal/namespace.js";
|
|
27
27
|
export type { Outcome, Result, RunOptions } from "./internal/run.js";
|
|
28
28
|
export type { Site } from "./internal/frames.js";
|
|
29
|
-
export type { SpyCall, SpyResult } from "./internal/spy.js";
|
|
29
|
+
export type { SpyCall, SpyResult, SpySettledResult } from "./internal/spy.js";
|
|
30
30
|
export type { Strictness } from "./internal/equality.js";
|
|
31
31
|
|
|
32
32
|
export {
|
package/internal/equality.js
CHANGED
|
@@ -34,7 +34,8 @@ const MAX_RENDER_DEPTH = 6;
|
|
|
34
34
|
/** Most entries of a collection the renderer shows before eliding. */
|
|
35
35
|
const MAX_RENDER_ENTRIES = 32;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/** Whether `value` is an object, as a refinement the checker can follow. */
|
|
38
|
+
function isObject(value: mixed): value is interface {} {
|
|
38
39
|
return typeof value === "object" && value !== null;
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -51,8 +52,8 @@ function tag(value: mixed): string {
|
|
|
51
52
|
*/
|
|
52
53
|
function ownKeys(value: interface {}, strictness: Strictness): Array<string | symbol> {
|
|
53
54
|
const strings = Object.keys(value).sort();
|
|
54
|
-
const symbols = Object.getOwnPropertySymbols(value).filter(
|
|
55
|
-
Object.
|
|
55
|
+
const symbols = Object.getOwnPropertySymbols(value).filter(
|
|
56
|
+
(symbol) => Object.getOwnPropertyDescriptor(value, symbol)?.enumerable === true,
|
|
56
57
|
);
|
|
57
58
|
const keys: Array<string | symbol> = [...strings, ...symbols];
|
|
58
59
|
if (strictness === "strict") {
|
|
@@ -199,7 +200,7 @@ export function equals(
|
|
|
199
200
|
return false;
|
|
200
201
|
}
|
|
201
202
|
for (const key of leftKeys) {
|
|
202
|
-
if (!Object.
|
|
203
|
+
if (!Object.hasOwn(right, key)) {
|
|
203
204
|
return false;
|
|
204
205
|
}
|
|
205
206
|
if (!equals((left as $FlowFixMe)[key], (right as $FlowFixMe)[key], nested, strictness)) {
|
|
@@ -233,10 +234,12 @@ export function matchesObject(received: mixed, expected: mixed, seen: Array<Pair
|
|
|
233
234
|
if (!Array.isArray(received) || received.length !== expected.length) {
|
|
234
235
|
return false;
|
|
235
236
|
}
|
|
236
|
-
return expected.every((item, index) =>
|
|
237
|
+
return expected.every((item: mixed, index: number) =>
|
|
238
|
+
matchesObject(received[index], item, nested),
|
|
239
|
+
);
|
|
237
240
|
}
|
|
238
241
|
for (const key of ownKeys(expected as $FlowFixMe, "loose")) {
|
|
239
|
-
if (!Object.
|
|
242
|
+
if (!Object.hasOwn(received, key)) {
|
|
240
243
|
return false;
|
|
241
244
|
}
|
|
242
245
|
if (!matchesObject((received as $FlowFixMe)[key], (expected as $FlowFixMe)[key], nested)) {
|
package/internal/expect.js
CHANGED
|
@@ -242,14 +242,6 @@ export type Expectation = Matchers<void> & {
|
|
|
242
242
|
*/
|
|
243
243
|
export type Expect = {
|
|
244
244
|
(received: mixed): Expectation,
|
|
245
|
-
// `flow/unclear-type` reads source text rather than an AST, and the shape it
|
|
246
|
-
// recognises as a property key rather than a type is a name at the start of
|
|
247
|
-
// a line or straight after `{`, `,` or `;`. `readonly any:` is neither, so
|
|
248
|
-
// the rule reports Jest's, Vitest's and Sinon's name for this matcher as an
|
|
249
|
-
// `any` type. The rule's own comment already lists `@uniflowed/test`'s
|
|
250
|
-
// `expect.any` among the false positives it exists to avoid; this is the one
|
|
251
|
-
// spelling it still cannot see past.
|
|
252
|
-
// uf-lint-disable-next-line flow/unclear-type
|
|
253
245
|
readonly any: (constructor: mixed) => AsymmetricMatcher,
|
|
254
246
|
readonly anything: () => AsymmetricMatcher,
|
|
255
247
|
readonly objectContaining: (expected: interface {}) => AsymmetricMatcher,
|
|
@@ -277,7 +269,7 @@ function propertyAt(
|
|
|
277
269
|
if (current == null) {
|
|
278
270
|
return { found: false, value: undefined };
|
|
279
271
|
}
|
|
280
|
-
if (!Object.
|
|
272
|
+
if (!Object.hasOwn(current as $FlowFixMe, key)) {
|
|
281
273
|
return { found: false, value: undefined };
|
|
282
274
|
}
|
|
283
275
|
current = (current as $FlowFixMe)[key];
|
|
@@ -585,8 +577,8 @@ function verdicts(received: mixed): {
|
|
|
585
577
|
requireSpy("toHaveBeenCalledWith");
|
|
586
578
|
const calls = spyCalls();
|
|
587
579
|
return simple(
|
|
588
|
-
calls.some((call) => equals([...call
|
|
589
|
-
`to have been called with ${render(args)}; the calls were ${render(calls
|
|
580
|
+
calls.some((call) => equals([...call], [...args])),
|
|
581
|
+
`to have been called with ${render(args)}; the calls were ${render(calls)}`,
|
|
590
582
|
args,
|
|
591
583
|
);
|
|
592
584
|
},
|
|
@@ -595,8 +587,8 @@ function verdicts(received: mixed): {
|
|
|
595
587
|
const calls = spyCalls();
|
|
596
588
|
const last = calls.length === 0 ? undefined : calls[calls.length - 1];
|
|
597
589
|
return simple(
|
|
598
|
-
last != null && equals([...last
|
|
599
|
-
`to have last been called with ${render(args)}, not ${render(last
|
|
590
|
+
last != null && equals([...last], [...args]),
|
|
591
|
+
`to have last been called with ${render(args)}, not ${render(last)}`,
|
|
600
592
|
args,
|
|
601
593
|
);
|
|
602
594
|
},
|
|
@@ -655,6 +647,7 @@ function verdicts(received: mixed): {
|
|
|
655
647
|
expected: render(value),
|
|
656
648
|
received: render(actual),
|
|
657
649
|
failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
|
|
650
|
+
negatedFailure: () => `expected ${render(name)} not to be ${render(value)}`,
|
|
658
651
|
};
|
|
659
652
|
},
|
|
660
653
|
toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
|
|
@@ -666,6 +659,8 @@ function verdicts(received: mixed): {
|
|
|
666
659
|
expected: render(wanted),
|
|
667
660
|
received: render(classes),
|
|
668
661
|
failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
|
|
662
|
+
negatedFailure: () =>
|
|
663
|
+
`expected the class list ${render(classes)} not to include ${render(wanted)}`,
|
|
669
664
|
};
|
|
670
665
|
},
|
|
671
666
|
toHaveTextContent: (expected: mixed) => {
|
|
@@ -678,6 +673,8 @@ function verdicts(received: mixed): {
|
|
|
678
673
|
expected: render(expected),
|
|
679
674
|
received: render(text),
|
|
680
675
|
failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
|
|
676
|
+
negatedFailure: () =>
|
|
677
|
+
`expected the text ${render(text)} not to contain ${render(expected)}`,
|
|
681
678
|
};
|
|
682
679
|
},
|
|
683
680
|
toHaveValue: (expected: mixed) => {
|
|
@@ -688,11 +685,12 @@ function verdicts(received: mixed): {
|
|
|
688
685
|
expected: render(expected),
|
|
689
686
|
received: render(actual),
|
|
690
687
|
failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
|
|
688
|
+
negatedFailure: () => `expected the value not to be ${render(expected)}`,
|
|
691
689
|
};
|
|
692
690
|
},
|
|
693
691
|
toHaveNoAxeViolations: async (options: mixed) => {
|
|
694
692
|
const node = element("toHaveNoAxeViolations");
|
|
695
|
-
const found = await auditElement(node,
|
|
693
|
+
const found = await auditElement(node, options as $FlowFixMe);
|
|
696
694
|
const named = violationIds(found);
|
|
697
695
|
return {
|
|
698
696
|
pass: found.length === 0,
|
package/internal/modules.js
CHANGED
|
@@ -57,7 +57,7 @@ import { frameFile, isInternalFrame } from "./frames.js";
|
|
|
57
57
|
import { fn } from "./spy.js";
|
|
58
58
|
|
|
59
59
|
/** The shape of a module's exports, as far as a type can say it. */
|
|
60
|
-
export type ModuleNamespace = {
|
|
60
|
+
export type ModuleNamespace = { readonly [string]: mixed };
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* What a `uft.mock` factory hands back.
|
|
@@ -181,7 +181,7 @@ export function resolveSpecifier(specifier: string, parentURL: string): string {
|
|
|
181
181
|
* calls and returns `undefined` — the automatic form, for a module whose shape
|
|
182
182
|
* a test wants to keep and whose behaviour it wants gone.
|
|
183
183
|
*/
|
|
184
|
-
export function mock<Module
|
|
184
|
+
export function mock<Module extends ModuleNamespace>(
|
|
185
185
|
specifier: string,
|
|
186
186
|
factory?: ModuleFactory<Module>,
|
|
187
187
|
): Promise<void> {
|
|
@@ -189,14 +189,14 @@ export function mock<Module: ModuleNamespace>(
|
|
|
189
189
|
const url = resolveSpecifier(specifier, callerURL("mock"));
|
|
190
190
|
|
|
191
191
|
if (factory == null) {
|
|
192
|
-
return importURL(actualUrl(url, isPathSpecifier(specifier))).then((actual) => {
|
|
192
|
+
return importURL<ModuleNamespace>(actualUrl(url, isPathSpecifier(specifier))).then((actual) => {
|
|
193
193
|
defineModuleMock(url, automock(actual, AUTOMOCK_DEPTH, new Map()));
|
|
194
194
|
});
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
const produced = factory();
|
|
198
198
|
if (isThenable(produced)) {
|
|
199
|
-
return (produced
|
|
199
|
+
return (produced as $FlowFixMe).then((namespace) => {
|
|
200
200
|
defineModuleMock(url, exportsOf(specifier, namespace));
|
|
201
201
|
});
|
|
202
202
|
}
|
|
@@ -225,7 +225,7 @@ export function unmock(specifier: string): void {
|
|
|
225
225
|
* also why `importActual` of a module that imports a mocked one still sees the
|
|
226
226
|
* stand-in.
|
|
227
227
|
*/
|
|
228
|
-
export function importActual<Module
|
|
228
|
+
export function importActual<Module extends ModuleNamespace>(specifier: string): Promise<Module> {
|
|
229
229
|
requireInterception("importActual");
|
|
230
230
|
const url = resolveSpecifier(specifier, callerURL("importActual"));
|
|
231
231
|
return importURL(actualUrl(url, isPathSpecifier(specifier)));
|
|
@@ -238,11 +238,11 @@ export function importActual<Module: ModuleNamespace>(specifier: string): Promis
|
|
|
238
238
|
* is a stand-in the caller holds, and every other importer of that module still
|
|
239
239
|
* gets whatever it got before.
|
|
240
240
|
*/
|
|
241
|
-
export function importMock<Module
|
|
241
|
+
export function importMock<Module extends ModuleNamespace>(specifier: string): Promise<Module> {
|
|
242
242
|
requireInterception("importMock");
|
|
243
243
|
const url = resolveSpecifier(specifier, callerURL("importMock"));
|
|
244
|
-
return importURL(actualUrl(url, isPathSpecifier(specifier))).then(
|
|
245
|
-
(actual) =>
|
|
244
|
+
return importURL<ModuleNamespace>(actualUrl(url, isPathSpecifier(specifier))).then(
|
|
245
|
+
(actual) => automock(actual, AUTOMOCK_DEPTH, new Map()) as $FlowFixMe,
|
|
246
246
|
);
|
|
247
247
|
}
|
|
248
248
|
|
|
@@ -287,7 +287,7 @@ function isPathSpecifier(specifier: string): boolean {
|
|
|
287
287
|
|
|
288
288
|
/** `import()`, in one place, so the marker parameter is never spelled twice. */
|
|
289
289
|
function importURL<Module>(url: string): Promise<Module> {
|
|
290
|
-
return
|
|
290
|
+
return import(url) as $FlowFixMe;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/** Whether `value` is a promise, or near enough for `then` to be meant. */
|
|
@@ -295,7 +295,7 @@ function isThenable(value: mixed): boolean {
|
|
|
295
295
|
return (
|
|
296
296
|
value != null &&
|
|
297
297
|
(typeof value === "object" || typeof value === "function") &&
|
|
298
|
-
typeof (value
|
|
298
|
+
typeof (value as $FlowFixMe).then === "function"
|
|
299
299
|
);
|
|
300
300
|
}
|
|
301
301
|
|
|
@@ -314,7 +314,7 @@ function exportsOf(specifier: string, produced: mixed): { [string]: mixed } {
|
|
|
314
314
|
`object, and it returned ${produced === null ? "null" : typeof produced}`,
|
|
315
315
|
);
|
|
316
316
|
}
|
|
317
|
-
return { ...(produced
|
|
317
|
+
return { ...(produced as $FlowFixMe) };
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
/**
|
|
@@ -344,10 +344,10 @@ function automock(value: mixed, depth: number, seen: Map<mixed, mixed>): mixed {
|
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
if (typeof value === "function") {
|
|
347
|
-
const spy = fn().mockName((value
|
|
347
|
+
const spy = fn().mockName((value as $FlowFixMe).name ?? "spy");
|
|
348
348
|
seen.set(value, spy);
|
|
349
349
|
copyProperties(value, spy, depth, seen, false);
|
|
350
|
-
const prototype = (value
|
|
350
|
+
const prototype = (value as $FlowFixMe).prototype;
|
|
351
351
|
if (prototype != null && typeof prototype === "object") {
|
|
352
352
|
// Every own name, not only the enumerable ones: a class's methods are
|
|
353
353
|
// non-enumerable own properties of its prototype, so `Object.keys` finds
|
|
@@ -390,8 +390,8 @@ function copyProperties(
|
|
|
390
390
|
seen: Map<mixed, mixed>,
|
|
391
391
|
hidden: boolean,
|
|
392
392
|
): void {
|
|
393
|
-
const source =
|
|
394
|
-
const target =
|
|
393
|
+
const source = from as $FlowFixMe;
|
|
394
|
+
const target = onto as $FlowFixMe;
|
|
395
395
|
const names = hidden ? Object.getOwnPropertyNames(source) : Object.keys(source);
|
|
396
396
|
for (const name of names) {
|
|
397
397
|
// `constructor` on a prototype points back at the function being mocked,
|
|
@@ -426,5 +426,5 @@ function isPlainish(value: mixed): boolean {
|
|
|
426
426
|
|
|
427
427
|
/** Whether `value` is a module namespace object. */
|
|
428
428
|
function isNamespace(value: mixed): boolean {
|
|
429
|
-
return (value
|
|
429
|
+
return (value as $FlowFixMe)[Symbol.toStringTag] === "Module";
|
|
430
430
|
}
|
package/internal/namespace.js
CHANGED
|
@@ -160,17 +160,22 @@ export async function waitFor<T>(
|
|
|
160
160
|
const deadline = Date.now() + timeout;
|
|
161
161
|
let last: mixed = null;
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// Ends when the deadline passes; the `throw` after it is the only way out
|
|
164
|
+
// that is not a result. A condition rather than `for (;;)`, because Flow does
|
|
165
|
+
// not treat an infinite loop as ending the function (facebook/flow#7657).
|
|
166
|
+
let expired = false;
|
|
167
|
+
while (!expired) {
|
|
164
168
|
try {
|
|
165
169
|
return await body();
|
|
166
170
|
} catch (thrown) {
|
|
167
171
|
last = thrown;
|
|
168
172
|
}
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
expired = Date.now() >= deadline;
|
|
174
|
+
if (!expired) {
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
171
176
|
}
|
|
172
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
173
177
|
}
|
|
178
|
+
throw last ?? new Error(`uft.waitFor: gave up after ${timeout}ms`);
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
/** Run `body` until it returns something truthy, or the timeout passes. */
|
package/internal/snapshot.js
CHANGED
|
@@ -56,7 +56,7 @@ const loaded: Map<string, { entries: { [string]: string }, dirty: boolean }> = n
|
|
|
56
56
|
|
|
57
57
|
/** Whether this run may rewrite a snapshot that did not match. */
|
|
58
58
|
function updating(): boolean {
|
|
59
|
-
const value = (globalThis
|
|
59
|
+
const value = (globalThis as $FlowFixMe).process?.env?.UF_UPDATE_SNAPSHOTS;
|
|
60
60
|
return value != null && value !== "" && value !== "0";
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -101,7 +101,7 @@ function entriesFor(file: string): { entries: { [string]: string }, dirty: boole
|
|
|
101
101
|
return already;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const state = { entries:
|
|
104
|
+
const state = { entries: Object.create(null) as $FlowFixMe, dirty: false };
|
|
105
105
|
if (existsSync(target)) {
|
|
106
106
|
parseInto(readFileSync(target, "utf8"), state.entries);
|
|
107
107
|
}
|
package/internal/spy.js
CHANGED
|
@@ -3,9 +3,27 @@
|
|
|
3
3
|
// Internal to `@uniflowed/test`: the spy behind `fn` and `uft.spyOn`.
|
|
4
4
|
//
|
|
5
5
|
// Shaped after Vitest's, because a project moving to uf should not have to
|
|
6
|
-
// rewrite its assertions. That means `mock
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// rewrite its assertions. That means `mock` holds exactly what Vitest's does,
|
|
7
|
+
// in the same shapes:
|
|
8
|
+
//
|
|
9
|
+
// * `calls` — one array of arguments per call, so `mock.calls[0][0]` is the
|
|
10
|
+
// first call's first argument and `mock.calls.map((args) => args[0])` works
|
|
11
|
+
// as it does there. It used to hold `{ args, returned }` objects, which read
|
|
12
|
+
// well and broke every assertion carried over from Vitest or Jest.
|
|
13
|
+
// * `results` — `{ type, value }` per call: `"return"` or `"throw"`, and
|
|
14
|
+
// `"incomplete"` while the call has not returned yet (a spy asked about
|
|
15
|
+
// itself from inside its own implementation).
|
|
16
|
+
// * `settledResults` — `{ type, value }` per call once the value settled:
|
|
17
|
+
// `"fulfilled"` or `"rejected"` for a promise, `"fulfilled"` at once for
|
|
18
|
+
// anything else, `"incomplete"` until then.
|
|
19
|
+
// * `contexts` — the `this` of each call; `instances` — the same, except that
|
|
20
|
+
// a call made with `new` records the instance it constructed, in both.
|
|
21
|
+
// * `invocationCallOrder` — a number per call, counted across every spy in the
|
|
22
|
+
// process, so two spies can say which was called first.
|
|
23
|
+
// * `lastCall` — the last call's arguments, or `undefined` before any.
|
|
24
|
+
//
|
|
25
|
+
// And the `Once` variants, and `mockReset` and `mockRestore` meaning the two
|
|
26
|
+
// different things they mean there.
|
|
9
27
|
//
|
|
10
28
|
// The three reset verbs are easy to conflate and are genuinely different:
|
|
11
29
|
//
|
|
@@ -18,17 +36,35 @@
|
|
|
18
36
|
// Every spy is registered, so `uft.clearAllMocks` and its siblings can reach the
|
|
19
37
|
// ones a test never held a reference to.
|
|
20
38
|
|
|
21
|
-
/** One call
|
|
22
|
-
export type SpyCall =
|
|
23
|
-
readonly args: $ReadOnlyArray<mixed>,
|
|
24
|
-
readonly returned?: mixed,
|
|
25
|
-
readonly threw?: mixed,
|
|
26
|
-
};
|
|
39
|
+
/** One call's arguments, as Vitest's `mock.calls` holds them. */
|
|
40
|
+
export type SpyCall = $ReadOnlyArray<mixed>;
|
|
27
41
|
|
|
28
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* One call's outcome, in the shape Vitest reports it.
|
|
44
|
+
*
|
|
45
|
+
* `"incomplete"` is a call that has not returned yet — only ever seen from
|
|
46
|
+
* inside the call itself — and its `value` is `undefined`.
|
|
47
|
+
*/
|
|
29
48
|
export type SpyResult =
|
|
30
49
|
| { readonly type: "return", readonly value: mixed }
|
|
31
|
-
| { readonly type: "throw", readonly value: mixed }
|
|
50
|
+
| { readonly type: "throw", readonly value: mixed }
|
|
51
|
+
| { readonly type: "incomplete", readonly value: void };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One call's value once it settled, in the shape Vitest reports it.
|
|
55
|
+
*
|
|
56
|
+
* A promise settles when it does; anything else settles as the call returns.
|
|
57
|
+
*/
|
|
58
|
+
export type SpySettledResult =
|
|
59
|
+
| { readonly type: "fulfilled", readonly value: mixed }
|
|
60
|
+
| { readonly type: "rejected", readonly value: mixed }
|
|
61
|
+
| { readonly type: "incomplete", readonly value: void };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The order calls happened in, across every spy in the process: Vitest's
|
|
65
|
+
* `invocationCallOrder`, which starts at one.
|
|
66
|
+
*/
|
|
67
|
+
let invocations = 0;
|
|
32
68
|
|
|
33
69
|
/** Every spy made in this process, so the `All` verbs can reach them. */
|
|
34
70
|
const registry: Array<$FlowFixMe> = [];
|
|
@@ -44,8 +80,11 @@ type Restore = null | (() => void);
|
|
|
44
80
|
*/
|
|
45
81
|
function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFixMe {
|
|
46
82
|
const calls: Array<SpyCall> = [];
|
|
47
|
-
const results: Array
|
|
83
|
+
const results: Array<$FlowFixMe> = [];
|
|
84
|
+
const settledResults: Array<$FlowFixMe> = [];
|
|
85
|
+
const contexts: Array<mixed> = [];
|
|
48
86
|
const instances: Array<mixed> = [];
|
|
87
|
+
const invocationCallOrder: Array<number> = [];
|
|
49
88
|
// Implementations queued by the `Once` variants, taken from the front.
|
|
50
89
|
const queued: Array<mixed> = [];
|
|
51
90
|
|
|
@@ -53,36 +92,84 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
|
|
|
53
92
|
let current = implementation;
|
|
54
93
|
let mockName = name;
|
|
55
94
|
|
|
56
|
-
const spy: $FlowFixMe = function (...args: $ReadOnlyArray<mixed>) {
|
|
95
|
+
const spy: $FlowFixMe = function (this: mixed, ...args: $ReadOnlyArray<mixed>) {
|
|
96
|
+
// Recorded before the implementation runs, as Vitest records it, so a spy
|
|
97
|
+
// that asks about itself from inside its own call sees that call already.
|
|
98
|
+
calls.push(args);
|
|
99
|
+
invocations += 1;
|
|
100
|
+
invocationCallOrder.push(invocations);
|
|
101
|
+
const result: $FlowFixMe = { type: "incomplete", value: undefined };
|
|
102
|
+
const settled: $FlowFixMe = { type: "incomplete", value: undefined };
|
|
103
|
+
results.push(result);
|
|
104
|
+
settledResults.push(settled);
|
|
57
105
|
// `this` is recorded because a spy on a method is often called as one, and
|
|
58
|
-
// `mock.
|
|
59
|
-
|
|
106
|
+
// `mock.contexts` is how a test asserts on the receiver. A construction has
|
|
107
|
+
// no receiver yet; the instance it makes is recorded once there is one.
|
|
108
|
+
const constructing = new.target !== undefined;
|
|
109
|
+
const context = constructing ? undefined : this;
|
|
110
|
+
const at = contexts.push(context) - 1;
|
|
111
|
+
instances.push(context);
|
|
60
112
|
const body = queued.length > 0 ? queued.shift() : current;
|
|
113
|
+
let returned;
|
|
61
114
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
115
|
+
if (constructing) {
|
|
116
|
+
returned =
|
|
117
|
+
typeof body === "function"
|
|
118
|
+
? Reflect.construct(body as $FlowFixMe, [...args], new.target)
|
|
119
|
+
: this;
|
|
120
|
+
} else {
|
|
121
|
+
returned = typeof body === "function" ? body.apply(this, args) : undefined;
|
|
122
|
+
}
|
|
66
123
|
} catch (thrown) {
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
result.type = "throw";
|
|
125
|
+
result.value = thrown;
|
|
126
|
+
settled.type = "rejected";
|
|
127
|
+
settled.value = thrown;
|
|
69
128
|
throw thrown;
|
|
70
129
|
}
|
|
130
|
+
result.type = "return";
|
|
131
|
+
result.value = returned;
|
|
132
|
+
if (constructing) {
|
|
133
|
+
contexts[at] = returned;
|
|
134
|
+
instances[at] = returned;
|
|
135
|
+
}
|
|
136
|
+
if (returned instanceof Promise) {
|
|
137
|
+
returned.then(
|
|
138
|
+
(value) => {
|
|
139
|
+
settled.type = "fulfilled";
|
|
140
|
+
settled.value = value;
|
|
141
|
+
},
|
|
142
|
+
(reason) => {
|
|
143
|
+
settled.type = "rejected";
|
|
144
|
+
settled.value = reason;
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
settled.type = "fulfilled";
|
|
149
|
+
settled.value = returned;
|
|
150
|
+
}
|
|
151
|
+
return returned;
|
|
71
152
|
};
|
|
72
153
|
|
|
73
154
|
spy.mock = {
|
|
74
155
|
calls,
|
|
75
156
|
results,
|
|
157
|
+
settledResults,
|
|
158
|
+
contexts,
|
|
76
159
|
instances,
|
|
77
|
-
|
|
78
|
-
|
|
160
|
+
invocationCallOrder,
|
|
161
|
+
get lastCall(): SpyCall | void {
|
|
162
|
+
return calls.length === 0 ? undefined : calls[calls.length - 1];
|
|
79
163
|
},
|
|
80
164
|
};
|
|
81
165
|
|
|
82
166
|
spy.mockClear = () => {
|
|
83
167
|
calls.length = 0;
|
|
84
168
|
results.length = 0;
|
|
169
|
+
settledResults.length = 0;
|
|
170
|
+
contexts.length = 0;
|
|
85
171
|
instances.length = 0;
|
|
172
|
+
invocationCallOrder.length = 0;
|
|
86
173
|
return spy;
|
|
87
174
|
};
|
|
88
175
|
spy.mockReset = () => {
|
|
@@ -114,8 +201,8 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
|
|
|
114
201
|
const out = body();
|
|
115
202
|
// An async body has to put the implementation back when it settles, not
|
|
116
203
|
// when it starts, or the next test runs against this one's stand-in.
|
|
117
|
-
if (out != null && typeof (out
|
|
118
|
-
return (out
|
|
204
|
+
if (out != null && typeof (out as $FlowFixMe).then === "function") {
|
|
205
|
+
return (out as $FlowFixMe).finally(() => {
|
|
119
206
|
current = previous;
|
|
120
207
|
});
|
|
121
208
|
}
|
|
@@ -136,7 +223,7 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
|
|
|
136
223
|
spy.mockRejectedValueOnce = (reason: mixed) =>
|
|
137
224
|
spy.mockImplementationOnce(() => Promise.reject(reason));
|
|
138
225
|
spy.mockReturnThis = () =>
|
|
139
|
-
spy.mockImplementation(function () {
|
|
226
|
+
spy.mockImplementation(function (this: mixed) {
|
|
140
227
|
return this;
|
|
141
228
|
});
|
|
142
229
|
|
|
@@ -203,7 +290,7 @@ export function spyOn(object: mixed, method: string): $FlowFixMe {
|
|
|
203
290
|
|
|
204
291
|
/** Whether `value` is one of these spies. */
|
|
205
292
|
export function isSpy(value: mixed): boolean {
|
|
206
|
-
return typeof value === "function" && (value
|
|
293
|
+
return typeof value === "function" && (value as $FlowFixMe).mock != null;
|
|
207
294
|
}
|
|
208
295
|
|
|
209
296
|
/** Forget every spy's calls, keeping their implementations. */
|
package/internal/timers.js
CHANGED
|
@@ -87,7 +87,9 @@ export function installFakeClock(): void {
|
|
|
87
87
|
return;
|
|
88
88
|
}
|
|
89
89
|
const global = host();
|
|
90
|
-
installed
|
|
90
|
+
// Kept in a local as well as the module's `installed`: the calls below would
|
|
91
|
+
// otherwise make Flow forget that `installed` was just set.
|
|
92
|
+
const saved = {
|
|
91
93
|
setTimeout: global.setTimeout,
|
|
92
94
|
clearTimeout: global.clearTimeout,
|
|
93
95
|
setInterval: global.setInterval,
|
|
@@ -96,6 +98,7 @@ export function installFakeClock(): void {
|
|
|
96
98
|
clearImmediate: global.clearImmediate,
|
|
97
99
|
Date: global.Date,
|
|
98
100
|
};
|
|
101
|
+
installed = saved;
|
|
99
102
|
|
|
100
103
|
now = Date.now();
|
|
101
104
|
tasks = [];
|
|
@@ -116,22 +119,23 @@ export function installFakeClock(): void {
|
|
|
116
119
|
global.clearTimeout = cancel;
|
|
117
120
|
global.clearInterval = cancel;
|
|
118
121
|
global.clearImmediate = cancel;
|
|
119
|
-
global.Date = fakeDate(
|
|
122
|
+
global.Date = fakeDate(saved.Date as $FlowFixMe);
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** Put the real scheduling globals back. */
|
|
123
126
|
export function restoreRealClock(): void {
|
|
124
|
-
|
|
127
|
+
const saved = installed;
|
|
128
|
+
if (saved == null) {
|
|
125
129
|
return;
|
|
126
130
|
}
|
|
127
131
|
const global = host();
|
|
128
|
-
global.setTimeout =
|
|
129
|
-
global.clearTimeout =
|
|
130
|
-
global.setInterval =
|
|
131
|
-
global.clearInterval =
|
|
132
|
-
global.setImmediate =
|
|
133
|
-
global.clearImmediate =
|
|
134
|
-
global.Date =
|
|
132
|
+
global.setTimeout = saved.setTimeout;
|
|
133
|
+
global.clearTimeout = saved.clearTimeout;
|
|
134
|
+
global.setInterval = saved.setInterval;
|
|
135
|
+
global.clearInterval = saved.clearInterval;
|
|
136
|
+
global.setImmediate = saved.setImmediate;
|
|
137
|
+
global.clearImmediate = saved.clearImmediate;
|
|
138
|
+
global.Date = saved.Date;
|
|
135
139
|
installed = null;
|
|
136
140
|
tasks = [];
|
|
137
141
|
}
|
|
@@ -167,7 +171,7 @@ function fakeDate(Real: $FlowFixMe): $FlowFixMe {
|
|
|
167
171
|
construct(target: $FlowFixMe, args: $ReadOnlyArray<mixed>, newTarget: $FlowFixMe) {
|
|
168
172
|
// Only the no-argument form reads the clock; every other form is
|
|
169
173
|
// constructing a specific date and has nothing to do with "now".
|
|
170
|
-
const actual = args.length === 0 ? [now] : args;
|
|
174
|
+
const actual: Array<mixed> = args.length === 0 ? [now] : [...args];
|
|
171
175
|
// `newTarget` rather than `Real`, so a subclass of the faked `Date` gets
|
|
172
176
|
// its own prototype instead of the real one's.
|
|
173
177
|
return Reflect.construct(Real, actual, newTarget === undefined ? Real : newTarget);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"!*.test.js"
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@uniflowed/host": "0.
|
|
41
|
+
"@uniflowed/host": "0.3.0",
|
|
42
42
|
"pixelmatch": "^7.1.0",
|
|
43
43
|
"pngjs": "^7.0.0"
|
|
44
44
|
},
|
package/worker.js
CHANGED
|
@@ -54,6 +54,9 @@ import { writeChangedSnapshots } from "./internal/snapshot.js";
|
|
|
54
54
|
import { createInterface } from "node:readline";
|
|
55
55
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
56
56
|
|
|
57
|
+
import fs from "node:fs";
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
|
|
57
60
|
import { installInSourceTests } from "./in-source.js";
|
|
58
61
|
import { restoreSharedState } from "./internal/isolation.js";
|
|
59
62
|
import { run } from "./internal/run.js";
|
|
@@ -72,9 +75,16 @@ import { run } from "./internal/run.js";
|
|
|
72
75
|
// has the global like Node and Bun. A shim for a host uf no longer starts would
|
|
73
76
|
// be a line claiming to be load-bearing while holding nothing up.
|
|
74
77
|
|
|
75
|
-
/** What `uf` sends for one file. */
|
|
78
|
+
/** What `uf` sends for one file, or to say which files changed. */
|
|
76
79
|
type Request = {|
|
|
77
|
-
readonly file
|
|
80
|
+
readonly file?: string,
|
|
81
|
+
/**
|
|
82
|
+
* Files that changed since this worker last ran one, as absolute paths.
|
|
83
|
+
*
|
|
84
|
+
* Sent on its own, between runs, to a `uf test --watch` worker that is kept
|
|
85
|
+
* for the next run; see [`invalidate`].
|
|
86
|
+
*/
|
|
87
|
+
readonly invalidate?: $ReadOnlyArray<string>,
|
|
78
88
|
readonly filter?: string | null,
|
|
79
89
|
readonly timeoutMs?: number,
|
|
80
90
|
/**
|
|
@@ -86,6 +96,12 @@ type Request = {|
|
|
|
86
96
|
readonly generation?: number,
|
|
87
97
|
|};
|
|
88
98
|
|
|
99
|
+
/** A request that names a file, which is every request but an invalidation. */
|
|
100
|
+
type FileRequest = {|
|
|
101
|
+
...Request,
|
|
102
|
+
readonly file: string,
|
|
103
|
+
|};
|
|
104
|
+
|
|
89
105
|
/**
|
|
90
106
|
* The request whose work the code running right now descends from.
|
|
91
107
|
*
|
|
@@ -159,11 +175,11 @@ function write(event: { readonly [string]: mixed }): void {
|
|
|
159
175
|
* run, set once on the worker by `uf`, and not something each request says.
|
|
160
176
|
*/
|
|
161
177
|
function benching(): boolean {
|
|
162
|
-
const value = (globalThis
|
|
178
|
+
const value = (globalThis as $FlowFixMe).process?.env?.UF_TEST_BENCH;
|
|
163
179
|
return value != null && value !== "" && value !== "0";
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
async function runFile(request:
|
|
182
|
+
async function runFile(request: FileRequest, generation: number): Promise<void> {
|
|
167
183
|
const started = performance.now();
|
|
168
184
|
// Everything the previous file changed and this package shares with it goes
|
|
169
185
|
// back: the registry, the stubbed environment and globals, the clock, the
|
|
@@ -213,7 +229,7 @@ async function runFile(request: Request, generation: number): Promise<void> {
|
|
|
213
229
|
* either of the two `return`s below escaping it.
|
|
214
230
|
*/
|
|
215
231
|
async function runImportedFile(
|
|
216
|
-
request:
|
|
232
|
+
request: FileRequest,
|
|
217
233
|
generation: number,
|
|
218
234
|
url: string,
|
|
219
235
|
started: number,
|
|
@@ -285,6 +301,25 @@ async function runImportedFile(
|
|
|
285
301
|
}
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Load the modules `changed` reaches afresh from the next import on.
|
|
306
|
+
*
|
|
307
|
+
* A `uf test --watch` worker is kept between runs, and an edit has to reach
|
|
308
|
+
* it: the changed files, and every module this process loaded that imports
|
|
309
|
+
* one, are given a new URL by `@uniflowed/host`'s resolve hook, so the next
|
|
310
|
+
* file imports them as they are on disk now and shares everything else, as a
|
|
311
|
+
* worker's files always have. `false` when that cannot be promised — no such
|
|
312
|
+
* hook in this process, or an edit that reaches a module `require()` loaded —
|
|
313
|
+
* and `uf` then runs the next file in a fresh worker instead.
|
|
314
|
+
*/
|
|
315
|
+
function invalidate(changed: $ReadOnlyArray<string>): boolean {
|
|
316
|
+
const epochs = (globalThis as $FlowFixMe)[Symbol.for("@uniflowed/host/module-epochs")];
|
|
317
|
+
if (epochs == null || typeof epochs.invalidate !== "function") {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return epochs.invalidate(changed) === true;
|
|
321
|
+
}
|
|
322
|
+
|
|
288
323
|
/**
|
|
289
324
|
* Serve requests until stdin closes.
|
|
290
325
|
*
|
|
@@ -321,13 +356,24 @@ function serve(): void {
|
|
|
321
356
|
return;
|
|
322
357
|
}
|
|
323
358
|
served += 1;
|
|
359
|
+
if (request.file == null) {
|
|
360
|
+
const at = request.generation ?? served;
|
|
361
|
+
const changed = request.invalidate ?? [];
|
|
362
|
+
queue = queue.then(() =>
|
|
363
|
+
serving.run(at, () => {
|
|
364
|
+
write({ event: "invalidated", ok: invalidate(changed) });
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const file = request.file;
|
|
324
370
|
// `uf` chooses the number, because `uf` is the side that checks it. This
|
|
325
371
|
// count of served requests is the same sequence and stands in for a `uf`
|
|
326
372
|
// too old to send one — without something monotonic here the import below
|
|
327
373
|
// would be cache-busted with `undefined` and a watch-mode rerun would see
|
|
328
374
|
// the module it already had.
|
|
329
375
|
const at = request.generation ?? served;
|
|
330
|
-
queue = queue.then(() => serving.run(at, () => runFile(request, at)));
|
|
376
|
+
queue = queue.then(() => serving.run(at, () => runFile({ ...request, file }, at)));
|
|
331
377
|
});
|
|
332
378
|
|
|
333
379
|
process.stdin.on("close", () => {
|
|
@@ -372,4 +418,48 @@ process.on("uncaughtException", (thrown: mixed) => {
|
|
|
372
418
|
process.exit(1);
|
|
373
419
|
});
|
|
374
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Give every test file its own copy of the project's modules.
|
|
423
|
+
*
|
|
424
|
+
* The Flow loader in `@uniflowed/host` imports every project module a test
|
|
425
|
+
* file reaches under a URL that names the file's run, so no module-level state
|
|
426
|
+
* — a React context's current value, a cache, a registry a package keeps —
|
|
427
|
+
* outlives the file that set it (ubugeeei-prod/uf#1443). Installed packages
|
|
428
|
+
* and the runner itself stay one instance per process; `internal/file-scope.js`
|
|
429
|
+
* in `@uniflowed/host` says why each is on its side of the line.
|
|
430
|
+
*
|
|
431
|
+
* The runner is named by directory: this package's and the loader's, as the
|
|
432
|
+
* loader will see them — real paths, because a workspace reaches both through
|
|
433
|
+
* a `node_modules` symlink and the loader is handed where they really are.
|
|
434
|
+
*/
|
|
435
|
+
function scopeModulesToFiles(): void {
|
|
436
|
+
const here = path.dirname(fileURLToPath(String(import.meta.url)));
|
|
437
|
+
let host: string | null = null;
|
|
438
|
+
try {
|
|
439
|
+
host = path.dirname(
|
|
440
|
+
fileURLToPath(String((import.meta as $FlowFixMe).resolve("@uniflowed/host/register"))),
|
|
441
|
+
);
|
|
442
|
+
} catch {
|
|
443
|
+
// A host without `import.meta.resolve`: the loader's own modules are
|
|
444
|
+
// reached from this package's, never from a test file's, so leaving them
|
|
445
|
+
// out of the list only matters for a test that imports the loader itself.
|
|
446
|
+
}
|
|
447
|
+
const shared = [here, host]
|
|
448
|
+
.filter((directory) => directory != null)
|
|
449
|
+
.map((directory) => {
|
|
450
|
+
try {
|
|
451
|
+
return fs.realpathSync(String(directory));
|
|
452
|
+
} catch {
|
|
453
|
+
return String(directory);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
Object.defineProperty(globalThis, Symbol.for("@uniflowed/host/file-scope"), {
|
|
457
|
+
value: Object.freeze({ shared: Object.freeze(shared) }),
|
|
458
|
+
configurable: true,
|
|
459
|
+
enumerable: false,
|
|
460
|
+
writable: false,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
scopeModulesToFiles();
|
|
375
465
|
serve();
|