@uniflowed/test 0.1.0 → 0.2.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/internal/expect.js +107 -53
- package/internal/frames.js +178 -0
- package/internal/registry.js +48 -14
- package/internal/run.js +30 -14
- package/package.json +2 -2
- package/worker.js +17 -0
package/internal/expect.js
CHANGED
|
@@ -814,58 +814,114 @@ function isDisabled(node: Element): boolean {
|
|
|
814
814
|
* `negated` decides which message a failing verdict raises, which is all of
|
|
815
815
|
* what `.not` is.
|
|
816
816
|
*
|
|
817
|
-
* # Why the
|
|
817
|
+
* # Why the matchers live on a prototype
|
|
818
818
|
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
* every
|
|
819
|
+
* `expect` is called once per assertion, and building its answer used to be
|
|
820
|
+
* the largest single cost of a passing assertion: the whole verdict table and a
|
|
821
|
+
* wrapper for each of its forty-one entries, eighty-odd closures, to call one
|
|
822
|
+
* of them. On a suite of 1,000 cases and 2,000 assertions that was about a
|
|
823
|
+
* twentieth of a worker's CPU, spent on functions nobody called.
|
|
824
|
+
*
|
|
825
|
+
* So the object carries only what differs between two assertions — the value
|
|
826
|
+
* and the polarity — and every matcher is a getter on one shared prototype,
|
|
827
|
+
* built the first time `expect` is used. The getter hands back a function
|
|
828
|
+
* closed over the object it was read from, rather than being a method that
|
|
829
|
+
* reads `this`: `const { toBe } = expect(1)` and `[1, 2].forEach(expect(n).not.toBe)`
|
|
830
|
+
* keep working, because the function a destructuring or a callback receives is
|
|
831
|
+
* already bound, which is what they got when every matcher was an own closure.
|
|
832
|
+
* The table is still built per *call*, from [`verdicts`], so a matcher still
|
|
833
|
+
* sees the one received value it was asked about and nothing else.
|
|
834
|
+
*
|
|
835
|
+
* `.not` is a getter on the same prototype for the reason it always was
|
|
836
|
+
* lazy: an expectation that built its negation would build *its* negation,
|
|
837
|
+
* forever. `.resolves` and `.rejects` are on a second prototype that only
|
|
838
|
+
* [`expectValue`]'s object has, so `expect(p).not.resolves` stays what it was —
|
|
839
|
+
* not a thing.
|
|
827
840
|
*/
|
|
828
|
-
|
|
829
|
-
|
|
841
|
+
type Bound = { readonly received: mixed, readonly negated: boolean, ... };
|
|
842
|
+
|
|
843
|
+
/** The two prototypes, built on first use; see [`bind`]. */
|
|
844
|
+
type Prototypes = {| readonly bound: interface {}, readonly root: interface {} |};
|
|
845
|
+
let prototypes: Prototypes | null = null;
|
|
846
|
+
|
|
847
|
+
/** A getter for matcher `name`, handing back a function bound to its object. */
|
|
848
|
+
function matcherGetter(name: string): (this: Bound) => (...args: $ReadOnlyArray<mixed>) => mixed {
|
|
849
|
+
return function (this: Bound) {
|
|
850
|
+
const self = this;
|
|
851
|
+
return (...args: $ReadOnlyArray<mixed>) => apply(self, name, args);
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function negation(this: Bound): mixed {
|
|
856
|
+
return bind(this.received, !this.negated);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function resolution(this: Bound): mixed {
|
|
860
|
+
return settled(this.received, "resolve", false);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function rejection(this: Bound): mixed {
|
|
864
|
+
return settled(this.received, "reject", false);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function matcherPrototypes(): Prototypes {
|
|
868
|
+
if (prototypes != null) {
|
|
869
|
+
return prototypes;
|
|
870
|
+
}
|
|
830
871
|
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
|
-
};
|
|
872
|
+
for (const name of Object.keys(verdicts(undefined))) {
|
|
873
|
+
Object.defineProperty(bound, name, { get: matcherGetter(name) });
|
|
867
874
|
}
|
|
868
|
-
Object.defineProperty(bound, "not", { get:
|
|
875
|
+
Object.defineProperty(bound, "not", { get: negation });
|
|
876
|
+
const root: $FlowFixMe = Object.create(bound);
|
|
877
|
+
Object.defineProperty(root, "resolves", { get: resolution });
|
|
878
|
+
Object.defineProperty(root, "rejects", { get: rejection });
|
|
879
|
+
prototypes = { bound, root };
|
|
880
|
+
return prototypes;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Decide matcher `name` on `bound`'s value, raising when it does not hold. */
|
|
884
|
+
function apply(bound: Bound, name: string, args: $ReadOnlyArray<mixed>): mixed {
|
|
885
|
+
const { received, negated } = bound;
|
|
886
|
+
const decide = (verdict: Verdict) => {
|
|
887
|
+
if (verdict.pass !== negated) {
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
const message = negated ? verdict.negatedFailure() : verdict.failure();
|
|
891
|
+
throw new AssertionError(
|
|
892
|
+
message,
|
|
893
|
+
name,
|
|
894
|
+
verdict.expected ?? "",
|
|
895
|
+
verdict.received ?? render(received),
|
|
896
|
+
);
|
|
897
|
+
};
|
|
898
|
+
const verdict = verdicts(received)[name](...args);
|
|
899
|
+
// A matcher whose engine is asynchronous answers with a promise of a
|
|
900
|
+
// verdict, and the promise is handed straight back rather than hidden.
|
|
901
|
+
//
|
|
902
|
+
// Hiding it was the alternative and it cannot be done: the only way to
|
|
903
|
+
// present an asynchronous answer synchronously is to decide before it
|
|
904
|
+
// arrives, which is deciding without it. What the promise costs is a
|
|
905
|
+
// forgotten `await`, and that case is not silent either — the rejection
|
|
906
|
+
// reaches the worker's unhandled-rejection handler, which fails the file
|
|
907
|
+
// the promise was created in and prints this same message. A missing
|
|
908
|
+
// `await` on a passing audit is the one case nothing reports, and it is
|
|
909
|
+
// the case where nothing happened.
|
|
910
|
+
//
|
|
911
|
+
// `instanceof Promise` rather than a `then` test, and it is safe for a
|
|
912
|
+
// reason that would not survive being generalised: every entry in the
|
|
913
|
+
// table is written in this file, so the only promise that can arrive
|
|
914
|
+
// here is one an `async` function in this module made, in this realm. A
|
|
915
|
+
// matcher registered from outside — which `@uniflowed/test` has no API
|
|
916
|
+
// for, deliberately — could hand back a foreign thenable, and this line
|
|
917
|
+
// would be the thing to revisit.
|
|
918
|
+
return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
922
|
+
const bound: $FlowFixMe = Object.create(matcherPrototypes().bound);
|
|
923
|
+
bound.received = received;
|
|
924
|
+
bound.negated = negated;
|
|
869
925
|
return bound;
|
|
870
926
|
}
|
|
871
927
|
|
|
@@ -938,11 +994,9 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
938
994
|
* received, for the reasons this module's header sets out.
|
|
939
995
|
*/
|
|
940
996
|
function expectValue(received: mixed): Expectation {
|
|
941
|
-
const expectation: $FlowFixMe =
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
945
|
-
Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
|
|
997
|
+
const expectation: $FlowFixMe = Object.create(matcherPrototypes().root);
|
|
998
|
+
expectation.received = received;
|
|
999
|
+
expectation.negated = false;
|
|
946
1000
|
return expectation;
|
|
947
1001
|
}
|
|
948
1002
|
|
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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.2.0",
|
|
42
42
|
"pixelmatch": "^7.1.0",
|
|
43
43
|
"pngjs": "^7.0.0"
|
|
44
44
|
},
|
package/worker.js
CHANGED
|
@@ -355,4 +355,21 @@ process.on("unhandledRejection", (reason: mixed) => {
|
|
|
355
355
|
process.exit(1);
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
// The same for an exception nothing caught — a server's `error` event with no
|
|
359
|
+
// listener, a throw from a timer callback. Node's default prints the stack to
|
|
360
|
+
// stderr and exits, and all `uf` could then say was that the worker had died:
|
|
361
|
+
// the file that did it went unnamed, and the message was somewhere above the
|
|
362
|
+
// report, unattributed. Reported the way a rejection is, it is the failure of
|
|
363
|
+
// the file whose work threw, with its message and stack in the file's result.
|
|
364
|
+
process.on("uncaughtException", (thrown: mixed) => {
|
|
365
|
+
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
366
|
+
write({
|
|
367
|
+
event: "file",
|
|
368
|
+
status: "run-failed",
|
|
369
|
+
message: `uncaught exception: ${error.message}`,
|
|
370
|
+
stack: error.stack ?? null,
|
|
371
|
+
});
|
|
372
|
+
process.exit(1);
|
|
373
|
+
});
|
|
374
|
+
|
|
358
375
|
serve();
|