@uniflowed/test 0.1.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 +119 -67
- package/internal/frames.js +178 -0
- package/internal/modules.js +16 -16
- package/internal/namespace.js +9 -4
- package/internal/registry.js +48 -14
- package/internal/run.js +30 -14
- 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 +113 -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,
|
|
@@ -814,58 +812,114 @@ function isDisabled(node: Element): boolean {
|
|
|
814
812
|
* `negated` decides which message a failing verdict raises, which is all of
|
|
815
813
|
* what `.not` is.
|
|
816
814
|
*
|
|
817
|
-
* # Why the
|
|
815
|
+
* # Why the matchers live on a prototype
|
|
818
816
|
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
* every
|
|
817
|
+
* `expect` is called once per assertion, and building its answer used to be
|
|
818
|
+
* the largest single cost of a passing assertion: the whole verdict table and a
|
|
819
|
+
* wrapper for each of its forty-one entries, eighty-odd closures, to call one
|
|
820
|
+
* of them. On a suite of 1,000 cases and 2,000 assertions that was about a
|
|
821
|
+
* twentieth of a worker's CPU, spent on functions nobody called.
|
|
822
|
+
*
|
|
823
|
+
* So the object carries only what differs between two assertions — the value
|
|
824
|
+
* and the polarity — and every matcher is a getter on one shared prototype,
|
|
825
|
+
* built the first time `expect` is used. The getter hands back a function
|
|
826
|
+
* closed over the object it was read from, rather than being a method that
|
|
827
|
+
* reads `this`: `const { toBe } = expect(1)` and `[1, 2].forEach(expect(n).not.toBe)`
|
|
828
|
+
* keep working, because the function a destructuring or a callback receives is
|
|
829
|
+
* already bound, which is what they got when every matcher was an own closure.
|
|
830
|
+
* The table is still built per *call*, from [`verdicts`], so a matcher still
|
|
831
|
+
* sees the one received value it was asked about and nothing else.
|
|
832
|
+
*
|
|
833
|
+
* `.not` is a getter on the same prototype for the reason it always was
|
|
834
|
+
* lazy: an expectation that built its negation would build *its* negation,
|
|
835
|
+
* forever. `.resolves` and `.rejects` are on a second prototype that only
|
|
836
|
+
* [`expectValue`]'s object has, so `expect(p).not.resolves` stays what it was —
|
|
837
|
+
* not a thing.
|
|
827
838
|
*/
|
|
828
|
-
|
|
829
|
-
|
|
839
|
+
type Bound = { readonly received: mixed, readonly negated: boolean, ... };
|
|
840
|
+
|
|
841
|
+
/** The two prototypes, built on first use; see [`bind`]. */
|
|
842
|
+
type Prototypes = {| readonly bound: interface {}, readonly root: interface {} |};
|
|
843
|
+
let prototypes: Prototypes | null = null;
|
|
844
|
+
|
|
845
|
+
/** A getter for matcher `name`, handing back a function bound to its object. */
|
|
846
|
+
function matcherGetter(name: string): (this: Bound) => (...args: $ReadOnlyArray<mixed>) => mixed {
|
|
847
|
+
return function (this: Bound) {
|
|
848
|
+
const self = this;
|
|
849
|
+
return (...args: $ReadOnlyArray<mixed>) => apply(self, name, args);
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function negation(this: Bound): mixed {
|
|
854
|
+
return bind(this.received, !this.negated);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function resolution(this: Bound): mixed {
|
|
858
|
+
return settled(this.received, "resolve", false);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function rejection(this: Bound): mixed {
|
|
862
|
+
return settled(this.received, "reject", false);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function matcherPrototypes(): Prototypes {
|
|
866
|
+
if (prototypes != null) {
|
|
867
|
+
return prototypes;
|
|
868
|
+
}
|
|
830
869
|
const bound: $FlowFixMe = {};
|
|
831
|
-
for (const name of Object.keys(
|
|
832
|
-
|
|
833
|
-
if (verdict.pass !== negated) {
|
|
834
|
-
return undefined;
|
|
835
|
-
}
|
|
836
|
-
const message = negated ? verdict.negatedFailure() : verdict.failure();
|
|
837
|
-
throw new AssertionError(
|
|
838
|
-
message,
|
|
839
|
-
name,
|
|
840
|
-
verdict.expected ?? "",
|
|
841
|
-
verdict.received ?? render(received),
|
|
842
|
-
);
|
|
843
|
-
};
|
|
844
|
-
bound[name] = (...args: $ReadOnlyArray<mixed>) => {
|
|
845
|
-
const verdict = table[name](...args);
|
|
846
|
-
// A matcher whose engine is asynchronous answers with a promise of a
|
|
847
|
-
// verdict, and the promise is handed straight back rather than hidden.
|
|
848
|
-
//
|
|
849
|
-
// Hiding it was the alternative and it cannot be done: the only way to
|
|
850
|
-
// present an asynchronous answer synchronously is to decide before it
|
|
851
|
-
// arrives, which is deciding without it. What the promise costs is a
|
|
852
|
-
// forgotten `await`, and that case is not silent either — the rejection
|
|
853
|
-
// reaches the worker's unhandled-rejection handler, which fails the file
|
|
854
|
-
// the promise was created in and prints this same message. A missing
|
|
855
|
-
// `await` on a passing audit is the one case nothing reports, and it is
|
|
856
|
-
// the case where nothing happened.
|
|
857
|
-
//
|
|
858
|
-
// `instanceof Promise` rather than a `then` test, and it is safe for a
|
|
859
|
-
// reason that would not survive being generalised: every entry in the
|
|
860
|
-
// table is written in this file, so the only promise that can arrive
|
|
861
|
-
// here is one an `async` function in this module made, in this realm. A
|
|
862
|
-
// matcher registered from outside — which `@uniflowed/test` has no API
|
|
863
|
-
// for, deliberately — could hand back a foreign thenable, and this line
|
|
864
|
-
// would be the thing to revisit.
|
|
865
|
-
return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
|
|
866
|
-
};
|
|
870
|
+
for (const name of Object.keys(verdicts(undefined))) {
|
|
871
|
+
Object.defineProperty(bound, name, { get: matcherGetter(name) });
|
|
867
872
|
}
|
|
868
|
-
Object.defineProperty(bound, "not", { get:
|
|
873
|
+
Object.defineProperty(bound, "not", { get: negation });
|
|
874
|
+
const root: $FlowFixMe = Object.create(bound);
|
|
875
|
+
Object.defineProperty(root, "resolves", { get: resolution });
|
|
876
|
+
Object.defineProperty(root, "rejects", { get: rejection });
|
|
877
|
+
prototypes = { bound, root };
|
|
878
|
+
return prototypes;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Decide matcher `name` on `bound`'s value, raising when it does not hold. */
|
|
882
|
+
function apply(bound: Bound, name: string, args: $ReadOnlyArray<mixed>): mixed {
|
|
883
|
+
const { received, negated } = bound;
|
|
884
|
+
const decide = (verdict: Verdict) => {
|
|
885
|
+
if (verdict.pass !== negated) {
|
|
886
|
+
return undefined;
|
|
887
|
+
}
|
|
888
|
+
const message = negated ? verdict.negatedFailure() : verdict.failure();
|
|
889
|
+
throw new AssertionError(
|
|
890
|
+
message,
|
|
891
|
+
name,
|
|
892
|
+
verdict.expected ?? "",
|
|
893
|
+
verdict.received ?? render(received),
|
|
894
|
+
);
|
|
895
|
+
};
|
|
896
|
+
const verdict = verdicts(received)[name](...args);
|
|
897
|
+
// A matcher whose engine is asynchronous answers with a promise of a
|
|
898
|
+
// verdict, and the promise is handed straight back rather than hidden.
|
|
899
|
+
//
|
|
900
|
+
// Hiding it was the alternative and it cannot be done: the only way to
|
|
901
|
+
// present an asynchronous answer synchronously is to decide before it
|
|
902
|
+
// arrives, which is deciding without it. What the promise costs is a
|
|
903
|
+
// forgotten `await`, and that case is not silent either — the rejection
|
|
904
|
+
// reaches the worker's unhandled-rejection handler, which fails the file
|
|
905
|
+
// the promise was created in and prints this same message. A missing
|
|
906
|
+
// `await` on a passing audit is the one case nothing reports, and it is
|
|
907
|
+
// the case where nothing happened.
|
|
908
|
+
//
|
|
909
|
+
// `instanceof Promise` rather than a `then` test, and it is safe for a
|
|
910
|
+
// reason that would not survive being generalised: every entry in the
|
|
911
|
+
// table is written in this file, so the only promise that can arrive
|
|
912
|
+
// here is one an `async` function in this module made, in this realm. A
|
|
913
|
+
// matcher registered from outside — which `@uniflowed/test` has no API
|
|
914
|
+
// for, deliberately — could hand back a foreign thenable, and this line
|
|
915
|
+
// would be the thing to revisit.
|
|
916
|
+
return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
920
|
+
const bound: $FlowFixMe = Object.create(matcherPrototypes().bound);
|
|
921
|
+
bound.received = received;
|
|
922
|
+
bound.negated = negated;
|
|
869
923
|
return bound;
|
|
870
924
|
}
|
|
871
925
|
|
|
@@ -938,11 +992,9 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
938
992
|
* received, for the reasons this module's header sets out.
|
|
939
993
|
*/
|
|
940
994
|
function expectValue(received: mixed): Expectation {
|
|
941
|
-
const expectation: $FlowFixMe =
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
945
|
-
Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
|
|
995
|
+
const expectation: $FlowFixMe = Object.create(matcherPrototypes().root);
|
|
996
|
+
expectation.received = received;
|
|
997
|
+
expectation.negated = false;
|
|
946
998
|
return expectation;
|
|
947
999
|
}
|
|
948
1000
|
|
package/internal/frames.js
CHANGED
|
@@ -195,3 +195,181 @@ export function userFrames(stack: string | null | void): string | null {
|
|
|
195
195
|
const frames = lines.slice(1).filter((frame) => !isInternalFrame(frame));
|
|
196
196
|
return frames.length === 0 ? head : [head, ...frames].join("\n");
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A V8 call site, as much of it as [`callerSite`] reads.
|
|
201
|
+
*
|
|
202
|
+
* Written out rather than imported: there is no library definition for V8's
|
|
203
|
+
* structured stack API, and these four methods are all it asks of one.
|
|
204
|
+
*/
|
|
205
|
+
type CallSite = interface {
|
|
206
|
+
getFileName(): ?string,
|
|
207
|
+
getLineNumber(): ?number,
|
|
208
|
+
getColumnNumber(): ?number,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** The piece of `node:module`'s source-map API [`callerSite`] needs. */
|
|
212
|
+
type SourceMapEntry = {|
|
|
213
|
+
readonly originalLine?: number,
|
|
214
|
+
readonly originalColumn?: number,
|
|
215
|
+
readonly originalSource?: string,
|
|
216
|
+
|};
|
|
217
|
+
type FindSourceMap = (
|
|
218
|
+
file: string,
|
|
219
|
+
) => ?interface { findEntry(line: number, column: number): SourceMapEntry };
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `node:module`'s `findSourceMap` when the host is Node with source maps on;
|
|
223
|
+
* `false` when the structured path must not be taken; `undefined` until asked.
|
|
224
|
+
*
|
|
225
|
+
* Asked once per process: the host does not change under a running worker.
|
|
226
|
+
*/
|
|
227
|
+
let nodeSourceMaps: FindSourceMap | false | void;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* How to map a generated position the way Node's own stack traces do, or
|
|
231
|
+
* `false` when this host's stacks cannot be reproduced from call sites.
|
|
232
|
+
*
|
|
233
|
+
* Node only. Bun and Deno implement the call-site API too, but whether their
|
|
234
|
+
* call sites carry the generated position or the mapped one is theirs to
|
|
235
|
+
* decide and has changed between releases, so they keep the string path,
|
|
236
|
+
* whose answer is by construction the one their `.stack` prints. A browser
|
|
237
|
+
* has no `process` at all.
|
|
238
|
+
*/
|
|
239
|
+
function sourceMapsForCallSites(): FindSourceMap | false {
|
|
240
|
+
if (nodeSourceMaps !== undefined) {
|
|
241
|
+
return nodeSourceMaps;
|
|
242
|
+
}
|
|
243
|
+
const host: $FlowFixMe = globalThis;
|
|
244
|
+
const process = host.process;
|
|
245
|
+
const isNode =
|
|
246
|
+
typeof process?.versions?.node === "string" &&
|
|
247
|
+
process.versions.bun == null &&
|
|
248
|
+
host.Deno == null &&
|
|
249
|
+
typeof process.getBuiltinModule === "function" &&
|
|
250
|
+
typeof Error.captureStackTrace === "function";
|
|
251
|
+
if (!isNode) {
|
|
252
|
+
nodeSourceMaps = false;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
const findSourceMap = process.getBuiltinModule("node:module")?.findSourceMap;
|
|
256
|
+
nodeSourceMaps =
|
|
257
|
+
typeof findSourceMap === "function"
|
|
258
|
+
? // Only while Node applies source maps to its own stacks: with them off
|
|
259
|
+
// a `.stack` prints the generated position, and so must this.
|
|
260
|
+
(file) => (process.sourceMapsEnabled === true ? findSourceMap(file) : null)
|
|
261
|
+
: false;
|
|
262
|
+
return nodeSourceMaps;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The first position outside the runner on the stack of the call to `skip`,
|
|
267
|
+
* as `firstUserSite(new Error().stack)` would read it — or `undefined` when
|
|
268
|
+
* this host cannot answer that way, and the caller should build the string.
|
|
269
|
+
*
|
|
270
|
+
* # Why not simply read `.stack`
|
|
271
|
+
*
|
|
272
|
+
* Because it is the most expensive line in registering a test. `describe` and
|
|
273
|
+
* `it` ask where they were called from, once per case, and on Node with
|
|
274
|
+
* `--enable-source-maps` — which every `uf test` worker runs with — the string
|
|
275
|
+
* `.stack` is built by mapping *every* frame through its module's source map
|
|
276
|
+
* and printing each one, to read back one line and column from the first that
|
|
277
|
+
* is not the runner's. On a suite of 50 files and 1,000 cases that was about a
|
|
278
|
+
* sixth of a worker's CPU.
|
|
279
|
+
*
|
|
280
|
+
* V8 hands the same frames over unprinted to a `prepareStackTrace` installed
|
|
281
|
+
* for the one capture, and only the frame the answer comes from is mapped,
|
|
282
|
+
* with the lookup Node's printer itself uses — `findSourceMap(file)` then
|
|
283
|
+
* `findEntry(line - 1, column - 1)`, falling back to the generated position
|
|
284
|
+
* when there is no map or no entry — so the number is the one the string would
|
|
285
|
+
* have carried. `packages/test/registration-site.test.js` holds the two paths
|
|
286
|
+
* to that.
|
|
287
|
+
*/
|
|
288
|
+
export function callerSite(skip: (...args: $ReadOnlyArray<empty>) => mixed): Site | null | void {
|
|
289
|
+
const findSourceMap = sourceMapsForCallSites();
|
|
290
|
+
if (findSourceMap === false) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const errors: $FlowFixMe = Error;
|
|
294
|
+
const limit: mixed = errors.stackTraceLimit;
|
|
295
|
+
const full = typeof limit === "number" ? limit : 0;
|
|
296
|
+
// A few frames first. The caller of a registration is two or three frames
|
|
297
|
+
// above it — `it`, the modifier or `each` wrapper, `addCase` — and V8's cost
|
|
298
|
+
// is per frame it materialises, so the whole default ten is walked only for
|
|
299
|
+
// the rare caller that is deeper than that.
|
|
300
|
+
const shallow = Math.min(full, SHALLOW_FRAMES);
|
|
301
|
+
const first = readCallSites(skip, shallow);
|
|
302
|
+
if (first === undefined) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const found = firstUserCallSite(first, findSourceMap);
|
|
306
|
+
if (found != null || first.length < shallow || shallow === full) {
|
|
307
|
+
return found;
|
|
308
|
+
}
|
|
309
|
+
const again = readCallSites(skip, full);
|
|
310
|
+
return again === undefined ? undefined : firstUserCallSite(again, findSourceMap);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** How many frames [`callerSite`] asks for before it asks for all of them. */
|
|
314
|
+
const SHALLOW_FRAMES = 4;
|
|
315
|
+
|
|
316
|
+
/** Hands V8's call sites back unprinted; one function, so none is made per capture. */
|
|
317
|
+
function unprinted(_error: mixed, sites: $ReadOnlyArray<CallSite>): $ReadOnlyArray<CallSite> {
|
|
318
|
+
return sites;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Up to `limit` call sites above `skip`, or `undefined` when the host does not
|
|
323
|
+
* hand them over.
|
|
324
|
+
*/
|
|
325
|
+
function readCallSites(
|
|
326
|
+
skip: (...args: $ReadOnlyArray<empty>) => mixed,
|
|
327
|
+
limit: number,
|
|
328
|
+
): $ReadOnlyArray<CallSite> | void {
|
|
329
|
+
const errors: $FlowFixMe = Error;
|
|
330
|
+
const prepare = errors.prepareStackTrace;
|
|
331
|
+
const before = errors.stackTraceLimit;
|
|
332
|
+
const holder: $FlowFixMe = {};
|
|
333
|
+
try {
|
|
334
|
+
errors.prepareStackTrace = unprinted;
|
|
335
|
+
errors.stackTraceLimit = limit;
|
|
336
|
+
errors.captureStackTrace(holder, skip);
|
|
337
|
+
// Read inside the `try`: V8 formats `stack` lazily, on first access, and
|
|
338
|
+
// with whatever `prepareStackTrace` is installed *then*. What comes back is
|
|
339
|
+
// what `unprinted` returned, which V8 does not type.
|
|
340
|
+
const sites: $FlowFixMe = holder.stack;
|
|
341
|
+
return Array.isArray(sites) ? sites : undefined;
|
|
342
|
+
} finally {
|
|
343
|
+
errors.prepareStackTrace = prepare;
|
|
344
|
+
errors.stackTraceLimit = before;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The first of `sites` outside the runner, mapped as Node maps a printed frame. */
|
|
349
|
+
function firstUserCallSite(
|
|
350
|
+
sites: $ReadOnlyArray<CallSite>,
|
|
351
|
+
findSourceMap: FindSourceMap,
|
|
352
|
+
): Site | null {
|
|
353
|
+
for (const site of sites) {
|
|
354
|
+
const file = site.getFileName();
|
|
355
|
+
if (file == null || isInternalFrame(file)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const line = site.getLineNumber();
|
|
359
|
+
const column = site.getColumnNumber();
|
|
360
|
+
if (line == null || column == null) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const entry = findSourceMap(file)?.findEntry(line - 1, column - 1);
|
|
364
|
+
if (
|
|
365
|
+
entry?.originalSource != null &&
|
|
366
|
+
entry.originalSource !== "" &&
|
|
367
|
+
entry.originalLine != null &&
|
|
368
|
+
entry.originalColumn != null
|
|
369
|
+
) {
|
|
370
|
+
return { line: entry.originalLine + 1, column: entry.originalColumn + 1 };
|
|
371
|
+
}
|
|
372
|
+
return { line, column };
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
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/registry.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// * `.only` anywhere in the file restricts the file to marked cases and their
|
|
20
20
|
// ancestors; everything else is reported skipped, never silently dropped.
|
|
21
21
|
|
|
22
|
-
import { firstUserSite } from "./frames.js";
|
|
22
|
+
import { callerSite, firstUserSite } from "./frames.js";
|
|
23
23
|
|
|
24
24
|
/** The placeholders `it.each` substitutes a row into. */
|
|
25
25
|
const ROW_TOKEN = /%[sjdi]/g;
|
|
@@ -27,6 +27,16 @@ const ROW_TOKEN = /%[sjdi]/g;
|
|
|
27
27
|
/** What a test or hook body may return. */
|
|
28
28
|
export type Body = () => mixed | Promise<mixed>;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* One `beforeAll`, `afterAll`, `beforeEach` or `afterEach`.
|
|
32
|
+
*
|
|
33
|
+
* `timeoutMs` is the hook's own budget when its registration named one, and
|
|
34
|
+
* `null` when the hook runs under the budget of the case it belongs to. A hook
|
|
35
|
+
* that starts a process or a server needs longer than the cases it sets up for,
|
|
36
|
+
* and without a budget of its own the only lever was the file-wide timeout.
|
|
37
|
+
*/
|
|
38
|
+
export type Hook = {| readonly body: Body, readonly timeoutMs: number | null |};
|
|
39
|
+
|
|
30
40
|
/** The suffix written on a registration call. */
|
|
31
41
|
export type Modifier = "none" | "only" | "skip" | "todo";
|
|
32
42
|
|
|
@@ -63,10 +73,10 @@ export type Suite = {|
|
|
|
63
73
|
readonly name: string,
|
|
64
74
|
readonly modifier: Modifier,
|
|
65
75
|
readonly children: Array<Suite | Case>,
|
|
66
|
-
readonly beforeAll: Array<
|
|
67
|
-
readonly afterAll: Array<
|
|
68
|
-
readonly beforeEach: Array<
|
|
69
|
-
readonly afterEach: Array<
|
|
76
|
+
readonly beforeAll: Array<Hook>,
|
|
77
|
+
readonly afterAll: Array<Hook>,
|
|
78
|
+
readonly beforeEach: Array<Hook>,
|
|
79
|
+
readonly afterEach: Array<Hook>,
|
|
70
80
|
readonly line: number,
|
|
71
81
|
readonly column: number,
|
|
72
82
|
|};
|
|
@@ -118,6 +128,10 @@ export function collected(): Suite {
|
|
|
118
128
|
* treats as "unknown" rather than as line one.
|
|
119
129
|
*/
|
|
120
130
|
function callSite(): {| readonly line: number, readonly column: number |} {
|
|
131
|
+
const site = callerSite(callSite);
|
|
132
|
+
if (site !== undefined) {
|
|
133
|
+
return site ?? { line: 0, column: 0 };
|
|
134
|
+
}
|
|
121
135
|
return firstUserSite(new Error("position").stack) ?? { line: 0, column: 0 };
|
|
122
136
|
}
|
|
123
137
|
|
|
@@ -276,22 +290,42 @@ function formatRow(name: string, row: mixed): string {
|
|
|
276
290
|
});
|
|
277
291
|
}
|
|
278
292
|
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
|
|
293
|
+
/**
|
|
294
|
+
* A hook's own budget, from the second argument of its registration.
|
|
295
|
+
*
|
|
296
|
+
* `{ timeout }` is what `it` takes, and a bare number is what Jest and Vitest
|
|
297
|
+
* take — a suite moved from either writes `beforeAll(start, 30_000)`, and a
|
|
298
|
+
* budget that was silently dropped would fail as a timeout the file never
|
|
299
|
+
* asked for.
|
|
300
|
+
*/
|
|
301
|
+
function hookTimeout(options: ?(TestOptions | number)): number | null {
|
|
302
|
+
if (typeof options === "number") {
|
|
303
|
+
return options;
|
|
304
|
+
}
|
|
305
|
+
return options?.timeout ?? null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Run once before the first test in this suite that runs.
|
|
310
|
+
*
|
|
311
|
+
* When it fails, every case it was setting up for fails with its error: none
|
|
312
|
+
* of them runs against a setup that did not happen.
|
|
313
|
+
*/
|
|
314
|
+
export function beforeAll(body: Body, options?: TestOptions | number): void {
|
|
315
|
+
current.beforeAll.push({ body, timeoutMs: hookTimeout(options) });
|
|
282
316
|
}
|
|
283
317
|
|
|
284
318
|
/** Run once after the last test in this suite that ran. */
|
|
285
|
-
export function afterAll(body: Body): void {
|
|
286
|
-
current.afterAll.push(body);
|
|
319
|
+
export function afterAll(body: Body, options?: TestOptions | number): void {
|
|
320
|
+
current.afterAll.push({ body, timeoutMs: hookTimeout(options) });
|
|
287
321
|
}
|
|
288
322
|
|
|
289
323
|
/** Run before every test in this suite and its children. */
|
|
290
|
-
export function beforeEach(body: Body): void {
|
|
291
|
-
current.beforeEach.push(body);
|
|
324
|
+
export function beforeEach(body: Body, options?: TestOptions | number): void {
|
|
325
|
+
current.beforeEach.push({ body, timeoutMs: hookTimeout(options) });
|
|
292
326
|
}
|
|
293
327
|
|
|
294
328
|
/** Run after every test in this suite and its children, including failures. */
|
|
295
|
-
export function afterEach(body: Body): void {
|
|
296
|
-
current.afterEach.push(body);
|
|
329
|
+
export function afterEach(body: Body, options?: TestOptions | number): void {
|
|
330
|
+
current.afterEach.push({ body, timeoutMs: hookTimeout(options) });
|
|
297
331
|
}
|
package/internal/run.js
CHANGED
|
@@ -13,7 +13,14 @@ import * as output from "./output.js";
|
|
|
13
13
|
import * as snapshot from "./snapshot.js";
|
|
14
14
|
import { AssertionError } from "./expect.js";
|
|
15
15
|
import { type Site, firstUserSite, siteInFile, userFrames } from "./frames.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type BenchOptions,
|
|
18
|
+
type Body,
|
|
19
|
+
type Case,
|
|
20
|
+
type Hook,
|
|
21
|
+
type Suite,
|
|
22
|
+
collected,
|
|
23
|
+
} from "./registry.js";
|
|
17
24
|
|
|
18
25
|
/** How one case ended. */
|
|
19
26
|
export type Outcome =
|
|
@@ -186,8 +193,8 @@ function failure(thrown: mixed, file: string | null): Outcome {
|
|
|
186
193
|
/** Everything one case needs from the suites above it. */
|
|
187
194
|
type Context = {|
|
|
188
195
|
readonly path: $ReadOnlyArray<string>,
|
|
189
|
-
readonly beforeEach: $ReadOnlyArray<
|
|
190
|
-
readonly afterEach: $ReadOnlyArray<
|
|
196
|
+
readonly beforeEach: $ReadOnlyArray<Hook>,
|
|
197
|
+
readonly afterEach: $ReadOnlyArray<Hook>,
|
|
191
198
|
readonly skipped: boolean,
|
|
192
199
|
readonly onlyPath: boolean,
|
|
193
200
|
|};
|
|
@@ -268,7 +275,7 @@ async function runCase(
|
|
|
268
275
|
await output.runInTest(name, async () => {
|
|
269
276
|
try {
|
|
270
277
|
for (const hook of context.beforeEach) {
|
|
271
|
-
await withTimeout(hook, timeoutMs);
|
|
278
|
+
await withTimeout(hook.body, hook.timeoutMs ?? timeoutMs);
|
|
272
279
|
}
|
|
273
280
|
if (benchmark) {
|
|
274
281
|
samples = await measure(body, test.bench, timeoutMs);
|
|
@@ -282,7 +289,7 @@ async function runCase(
|
|
|
282
289
|
// when the body had not already failed.
|
|
283
290
|
for (const hook of context.afterEach) {
|
|
284
291
|
try {
|
|
285
|
-
await withTimeout(hook, timeoutMs);
|
|
292
|
+
await withTimeout(hook.body, hook.timeoutMs ?? timeoutMs);
|
|
286
293
|
} catch (thrown) {
|
|
287
294
|
if (outcome.status === "passed") {
|
|
288
295
|
outcome = failure(thrown, options.file ?? null);
|
|
@@ -396,16 +403,25 @@ async function runSuite(
|
|
|
396
403
|
// every `beforeAll` in an ordinary file — one where the tests live inside a
|
|
397
404
|
// `describe` — never running at all, silently. The chain is walked outermost
|
|
398
405
|
// first, so an inner suite's setup sees what the outer one did.
|
|
406
|
+
//
|
|
407
|
+
// Once, and remembered either way. The outcome is kept rather than a "done"
|
|
408
|
+
// flag, because a flag raised before the hooks ran let every case after the
|
|
409
|
+
// first go ahead when setup had failed: the first case reported the hook's
|
|
410
|
+
// error and the rest ran against a fixture that was never made, failing — or
|
|
411
|
+
// worse, passing — for reasons that had nothing to do with them.
|
|
399
412
|
let setUp = false;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
413
|
+
let setup: Promise<void> | null = null;
|
|
414
|
+
const setUpOnce = (): Promise<void> => {
|
|
415
|
+
if (setup == null) {
|
|
416
|
+
setUp = true;
|
|
417
|
+
setup = (async () => {
|
|
418
|
+
await setUpAncestors();
|
|
419
|
+
for (const hook of node.beforeAll) {
|
|
420
|
+
await withTimeout(hook.body, hook.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
408
423
|
}
|
|
424
|
+
return setup;
|
|
409
425
|
};
|
|
410
426
|
|
|
411
427
|
let passed = true;
|
|
@@ -455,7 +471,7 @@ async function runSuite(
|
|
|
455
471
|
if (setUp) {
|
|
456
472
|
for (const hook of node.afterAll) {
|
|
457
473
|
try {
|
|
458
|
-
await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
474
|
+
await withTimeout(hook.body, hook.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
459
475
|
} catch {
|
|
460
476
|
// A teardown failure cannot fail a test that already reported, and
|
|
461
477
|
// there is nothing left to attach it to; the file's own status carries
|
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", () => {
|
|
@@ -355,4 +401,65 @@ process.on("unhandledRejection", (reason: mixed) => {
|
|
|
355
401
|
process.exit(1);
|
|
356
402
|
});
|
|
357
403
|
|
|
404
|
+
// The same for an exception nothing caught — a server's `error` event with no
|
|
405
|
+
// listener, a throw from a timer callback. Node's default prints the stack to
|
|
406
|
+
// stderr and exits, and all `uf` could then say was that the worker had died:
|
|
407
|
+
// the file that did it went unnamed, and the message was somewhere above the
|
|
408
|
+
// report, unattributed. Reported the way a rejection is, it is the failure of
|
|
409
|
+
// the file whose work threw, with its message and stack in the file's result.
|
|
410
|
+
process.on("uncaughtException", (thrown: mixed) => {
|
|
411
|
+
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
412
|
+
write({
|
|
413
|
+
event: "file",
|
|
414
|
+
status: "run-failed",
|
|
415
|
+
message: `uncaught exception: ${error.message}`,
|
|
416
|
+
stack: error.stack ?? null,
|
|
417
|
+
});
|
|
418
|
+
process.exit(1);
|
|
419
|
+
});
|
|
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();
|
|
358
465
|
serve();
|