@uniflowed/test 0.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +35 -0
- package/internal/equality.js +322 -0
- package/internal/expect.js +407 -0
- package/internal/frames.js +105 -0
- package/internal/registry.js +243 -0
- package/internal/run.js +314 -0
- package/package.json +23 -0
- package/worker.js +132 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `expect`, and the matchers it carries.
|
|
4
|
+
//
|
|
5
|
+
// A failed matcher throws an `AssertionError` whose message already says what
|
|
6
|
+
// was wanted and what arrived; the runner never reformats it, so what a person
|
|
7
|
+
// reads is what the matcher decided to say. Every matcher works under `.not`
|
|
8
|
+
// without being written twice: a matcher returns a verdict carrying both
|
|
9
|
+
// messages, and negation chooses the other one.
|
|
10
|
+
//
|
|
11
|
+
// `.resolves` and `.rejects` settle the promise first and then apply the same
|
|
12
|
+
// matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
|
|
13
|
+
// the way the synchronous form does.
|
|
14
|
+
|
|
15
|
+
import { equals, matchesObject, render } from "./equality.js";
|
|
16
|
+
|
|
17
|
+
/** Thrown when a matcher does not hold. */
|
|
18
|
+
export class AssertionError extends Error {
|
|
19
|
+
/** What the assertion wanted, rendered. */
|
|
20
|
+
expected: string;
|
|
21
|
+
/** What arrived, rendered. */
|
|
22
|
+
received: string;
|
|
23
|
+
/** The matcher's name, e.g. `toEqual`. */
|
|
24
|
+
matcher: string;
|
|
25
|
+
|
|
26
|
+
constructor(message: string, matcher: string, expected: string, received: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "AssertionError";
|
|
29
|
+
this.matcher = matcher;
|
|
30
|
+
this.expected = expected;
|
|
31
|
+
this.received = received;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What a matcher decided, and how to say it either way. */
|
|
36
|
+
type Verdict = {|
|
|
37
|
+
+pass: boolean,
|
|
38
|
+
+failure: () => string,
|
|
39
|
+
+negatedFailure: () => string,
|
|
40
|
+
+expected?: string,
|
|
41
|
+
+received?: string,
|
|
42
|
+
|};
|
|
43
|
+
|
|
44
|
+
/** One recorded call to a spy. */
|
|
45
|
+
export type SpyCall = {|
|
|
46
|
+
+args: $ReadOnlyArray<mixed>,
|
|
47
|
+
+returned?: mixed,
|
|
48
|
+
+threw?: mixed,
|
|
49
|
+
|};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A spy that records its calls.
|
|
53
|
+
*
|
|
54
|
+
* `fn()` records and returns `undefined`; `fn(implementation)` records and
|
|
55
|
+
* delegates. A throw is recorded and then re-thrown, so wrapping a function in
|
|
56
|
+
* a spy never changes whether the code under test fails.
|
|
57
|
+
*/
|
|
58
|
+
export function fn(implementation?: (...args: $ReadOnlyArray<mixed>) => mixed): $FlowFixMe {
|
|
59
|
+
const calls: Array<SpyCall> = [];
|
|
60
|
+
let current = implementation;
|
|
61
|
+
|
|
62
|
+
const spy: $FlowFixMe = (...args: $ReadOnlyArray<mixed>) => {
|
|
63
|
+
try {
|
|
64
|
+
const returned = current == null ? undefined : current(...args);
|
|
65
|
+
calls.push({ args, returned });
|
|
66
|
+
return returned;
|
|
67
|
+
} catch (thrown) {
|
|
68
|
+
calls.push({ args, threw: thrown });
|
|
69
|
+
throw thrown;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
spy.mock = { calls };
|
|
73
|
+
spy.mockClear = () => {
|
|
74
|
+
calls.length = 0;
|
|
75
|
+
};
|
|
76
|
+
spy.mockReturnValue = (value: mixed) => {
|
|
77
|
+
current = () => value;
|
|
78
|
+
return spy;
|
|
79
|
+
};
|
|
80
|
+
spy.mockResolvedValue = (value: mixed) => {
|
|
81
|
+
current = () => Promise.resolve(value);
|
|
82
|
+
return spy;
|
|
83
|
+
};
|
|
84
|
+
spy.mockRejectedValue = (reason: mixed) => {
|
|
85
|
+
current = () => Promise.reject(reason);
|
|
86
|
+
return spy;
|
|
87
|
+
};
|
|
88
|
+
spy.mockImplementation = (next: (...args: $ReadOnlyArray<mixed>) => mixed) => {
|
|
89
|
+
current = next;
|
|
90
|
+
return spy;
|
|
91
|
+
};
|
|
92
|
+
return spy;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSpy(value: mixed): boolean {
|
|
96
|
+
return typeof value === "function" && (value: $FlowFixMe).mock != null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function propertyAt(value: mixed, path: string): {| +found: boolean, +value: mixed |} {
|
|
100
|
+
let current = value;
|
|
101
|
+
for (const key of path.split(".")) {
|
|
102
|
+
if (current == null) {
|
|
103
|
+
return { found: false, value: undefined };
|
|
104
|
+
}
|
|
105
|
+
if (!Object.prototype.hasOwnProperty.call((current: $FlowFixMe), key)) {
|
|
106
|
+
return { found: false, value: undefined };
|
|
107
|
+
}
|
|
108
|
+
current = (current: $FlowFixMe)[key];
|
|
109
|
+
}
|
|
110
|
+
return { found: true, value: current };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function describeThrown(thrown: mixed): string {
|
|
114
|
+
return thrown instanceof Error ? `${thrown.name}: ${thrown.message}` : render(thrown);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchesThrown(thrown: mixed, expected: mixed): boolean {
|
|
118
|
+
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
|
119
|
+
if (typeof expected === "string") {
|
|
120
|
+
return message.includes(expected);
|
|
121
|
+
}
|
|
122
|
+
if (expected instanceof RegExp) {
|
|
123
|
+
return expected.test(message);
|
|
124
|
+
}
|
|
125
|
+
if (expected instanceof Error) {
|
|
126
|
+
return message === expected.message;
|
|
127
|
+
}
|
|
128
|
+
if (typeof expected === "function") {
|
|
129
|
+
return thrown instanceof (expected: $FlowFixMe);
|
|
130
|
+
}
|
|
131
|
+
return equals(thrown, expected);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The matcher table for one received value.
|
|
136
|
+
*
|
|
137
|
+
* Every entry returns a [`Verdict`] rather than throwing, which is what lets
|
|
138
|
+
* `.not` reuse all of them.
|
|
139
|
+
*/
|
|
140
|
+
function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>) => Verdict } {
|
|
141
|
+
const shown = () => render(received);
|
|
142
|
+
const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
|
|
143
|
+
pass,
|
|
144
|
+
expected: expected === undefined ? what : render(expected),
|
|
145
|
+
received: shown(),
|
|
146
|
+
failure: () => `expected ${shown()} ${what}`,
|
|
147
|
+
negatedFailure: () => `expected ${shown()} not ${what}`,
|
|
148
|
+
});
|
|
149
|
+
const spyCalls = (): Array<SpyCall> => (isSpy(received) ? (received: $FlowFixMe).mock.calls : []);
|
|
150
|
+
const requireSpy = (matcher: string) => {
|
|
151
|
+
if (!isSpy(received)) {
|
|
152
|
+
throw new AssertionError(
|
|
153
|
+
`${matcher} needs a spy made by \`fn()\`, but received ${shown()}`,
|
|
154
|
+
matcher,
|
|
155
|
+
"a spy",
|
|
156
|
+
shown(),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
toBe: (expected: mixed) => ({
|
|
163
|
+
pass: Object.is(received, expected),
|
|
164
|
+
expected: render(expected),
|
|
165
|
+
received: shown(),
|
|
166
|
+
failure: () => `expected ${shown()} to be ${render(expected)}`,
|
|
167
|
+
negatedFailure: () => `expected ${shown()} not to be ${render(expected)}`,
|
|
168
|
+
}),
|
|
169
|
+
toEqual: (expected: mixed) => ({
|
|
170
|
+
pass: equals(received, expected, [], "loose"),
|
|
171
|
+
expected: render(expected),
|
|
172
|
+
received: shown(),
|
|
173
|
+
failure: () => `expected ${shown()} to equal ${render(expected)}`,
|
|
174
|
+
negatedFailure: () => `expected ${shown()} not to equal ${render(expected)}`,
|
|
175
|
+
}),
|
|
176
|
+
toStrictEqual: (expected: mixed) => ({
|
|
177
|
+
pass: equals(received, expected, [], "strict"),
|
|
178
|
+
expected: render(expected),
|
|
179
|
+
received: shown(),
|
|
180
|
+
failure: () => `expected ${shown()} to strictly equal ${render(expected)}`,
|
|
181
|
+
negatedFailure: () => `expected ${shown()} not to strictly equal ${render(expected)}`,
|
|
182
|
+
}),
|
|
183
|
+
toBeTruthy: () => simple(Boolean(received), "to be truthy"),
|
|
184
|
+
toBeFalsy: () => simple(!received, "to be falsy"),
|
|
185
|
+
toBeNull: () => simple(received === null, "to be null"),
|
|
186
|
+
toBeUndefined: () => simple(received === undefined, "to be undefined"),
|
|
187
|
+
toBeDefined: () => simple(received !== undefined, "to be defined"),
|
|
188
|
+
toBeNaN: () => simple(typeof received === "number" && Number.isNaN(received), "to be NaN"),
|
|
189
|
+
toBeGreaterThan: (expected: mixed) =>
|
|
190
|
+
simple((received: $FlowFixMe) > (expected: $FlowFixMe), `to be greater than ${render(expected)}`, expected),
|
|
191
|
+
toBeGreaterThanOrEqual: (expected: mixed) =>
|
|
192
|
+
simple((received: $FlowFixMe) >= (expected: $FlowFixMe), `to be at least ${render(expected)}`, expected),
|
|
193
|
+
toBeLessThan: (expected: mixed) =>
|
|
194
|
+
simple((received: $FlowFixMe) < (expected: $FlowFixMe), `to be less than ${render(expected)}`, expected),
|
|
195
|
+
toBeLessThanOrEqual: (expected: mixed) =>
|
|
196
|
+
simple((received: $FlowFixMe) <= (expected: $FlowFixMe), `to be at most ${render(expected)}`, expected),
|
|
197
|
+
toBeCloseTo: (expected: number, digits?: number) => {
|
|
198
|
+
const places = digits ?? 2;
|
|
199
|
+
const tolerance = 10 ** -places / 2;
|
|
200
|
+
const difference = Math.abs((received: $FlowFixMe) - expected);
|
|
201
|
+
return simple(
|
|
202
|
+
difference < tolerance,
|
|
203
|
+
`to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
|
|
204
|
+
expected,
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
toContain: (expected: mixed) => {
|
|
208
|
+
const pass =
|
|
209
|
+
typeof received === "string"
|
|
210
|
+
? received.includes(String(expected))
|
|
211
|
+
: Array.isArray(received)
|
|
212
|
+
? received.some((item) => Object.is(item, expected))
|
|
213
|
+
: received instanceof Set
|
|
214
|
+
? received.has(expected)
|
|
215
|
+
: false;
|
|
216
|
+
return simple(pass, `to contain ${render(expected)}`, expected);
|
|
217
|
+
},
|
|
218
|
+
toContainEqual: (expected: mixed) => {
|
|
219
|
+
const items = Array.isArray(received) ? received : received instanceof Set ? [...received] : [];
|
|
220
|
+
return simple(
|
|
221
|
+
items.some((item) => equals(item, expected)),
|
|
222
|
+
`to contain something equal to ${render(expected)}`,
|
|
223
|
+
expected,
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
toHaveLength: (expected: number) => {
|
|
227
|
+
const length = received == null ? undefined : (received: $FlowFixMe).length;
|
|
228
|
+
return simple(length === expected, `to have length ${expected}, not ${render(length)}`, expected);
|
|
229
|
+
},
|
|
230
|
+
toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
|
|
231
|
+
const found = propertyAt(received, path);
|
|
232
|
+
if (rest.length === 0) {
|
|
233
|
+
return simple(found.found, `to have a property at \`${path}\``);
|
|
234
|
+
}
|
|
235
|
+
return simple(
|
|
236
|
+
found.found && equals(found.value, rest[0]),
|
|
237
|
+
`to have \`${path}\` equal to ${render(rest[0])}, not ${render(found.value)}`,
|
|
238
|
+
rest[0],
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
toMatch: (expected: mixed) => {
|
|
242
|
+
const text = typeof received === "string" ? received : String(received);
|
|
243
|
+
const pass = typeof expected === "string" ? text.includes(expected) : (expected: $FlowFixMe).test(text);
|
|
244
|
+
return simple(pass, `to match ${render(expected)}`, expected);
|
|
245
|
+
},
|
|
246
|
+
toMatchObject: (expected: mixed) =>
|
|
247
|
+
simple(matchesObject(received, expected), `to match ${render(expected)}`, expected),
|
|
248
|
+
toBeInstanceOf: (expected: mixed) =>
|
|
249
|
+
simple(
|
|
250
|
+
typeof expected === "function" && received instanceof (expected: $FlowFixMe),
|
|
251
|
+
`to be an instance of ${render(expected)}`,
|
|
252
|
+
expected,
|
|
253
|
+
),
|
|
254
|
+
toBeTypeOf: (expected: string) =>
|
|
255
|
+
simple(typeof received === expected, `to be of type ${expected}, not ${typeof received}`, expected),
|
|
256
|
+
toSatisfy: (predicate: (value: mixed) => boolean) =>
|
|
257
|
+
simple(predicate(received) === true, "to satisfy the predicate"),
|
|
258
|
+
toThrow: (...rest: $ReadOnlyArray<mixed>) => {
|
|
259
|
+
const expected = rest[0];
|
|
260
|
+
if (typeof received !== "function") {
|
|
261
|
+
return simple(false, "to be a function, so it could be called");
|
|
262
|
+
}
|
|
263
|
+
let thrown: mixed;
|
|
264
|
+
let threw = false;
|
|
265
|
+
try {
|
|
266
|
+
received();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
threw = true;
|
|
269
|
+
thrown = error;
|
|
270
|
+
}
|
|
271
|
+
if (!threw) {
|
|
272
|
+
return {
|
|
273
|
+
pass: false,
|
|
274
|
+
expected: rest.length === 0 ? "a throw" : render(expected),
|
|
275
|
+
received: "no throw",
|
|
276
|
+
failure: () => "expected the function to throw, but it returned",
|
|
277
|
+
negatedFailure: () => "expected the function not to throw",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
pass: rest.length === 0 || matchesThrown(thrown, expected),
|
|
282
|
+
expected: rest.length === 0 ? "a throw" : render(expected),
|
|
283
|
+
received: describeThrown(thrown),
|
|
284
|
+
failure: () =>
|
|
285
|
+
`expected the function to throw ${render(expected)}, but it threw ${describeThrown(thrown)}`,
|
|
286
|
+
negatedFailure: () => `expected the function not to throw ${describeThrown(thrown)}`,
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
toHaveBeenCalled: () => {
|
|
290
|
+
requireSpy("toHaveBeenCalled");
|
|
291
|
+
return simple(spyCalls().length > 0, "to have been called");
|
|
292
|
+
},
|
|
293
|
+
toHaveBeenCalledTimes: (count: number) => {
|
|
294
|
+
requireSpy("toHaveBeenCalledTimes");
|
|
295
|
+
const actual = spyCalls().length;
|
|
296
|
+
return simple(actual === count, `to have been called ${count} times, not ${actual}`, count);
|
|
297
|
+
},
|
|
298
|
+
toHaveBeenCalledWith: (...args: $ReadOnlyArray<mixed>) => {
|
|
299
|
+
requireSpy("toHaveBeenCalledWith");
|
|
300
|
+
const calls = spyCalls();
|
|
301
|
+
return simple(
|
|
302
|
+
calls.some((call) => equals([...call.args], [...args])),
|
|
303
|
+
`to have been called with ${render(args)}; the calls were ${render(calls.map((call) => call.args))}`,
|
|
304
|
+
args,
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
toHaveBeenLastCalledWith: (...args: $ReadOnlyArray<mixed>) => {
|
|
308
|
+
requireSpy("toHaveBeenLastCalledWith");
|
|
309
|
+
const calls = spyCalls();
|
|
310
|
+
const last = calls.length === 0 ? undefined : calls[calls.length - 1];
|
|
311
|
+
return simple(
|
|
312
|
+
last != null && equals([...last.args], [...args]),
|
|
313
|
+
`to have last been called with ${render(args)}, not ${render(last == null ? undefined : last.args)}`,
|
|
314
|
+
args,
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Turn the verdict table into the object a caller uses.
|
|
322
|
+
*
|
|
323
|
+
* `negated` decides which message a failing verdict raises, which is all of
|
|
324
|
+
* what `.not` is.
|
|
325
|
+
*/
|
|
326
|
+
function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
327
|
+
const table = verdicts(received);
|
|
328
|
+
const bound: $FlowFixMe = {};
|
|
329
|
+
for (const name of Object.keys(table)) {
|
|
330
|
+
bound[name] = (...args: $ReadOnlyArray<mixed>) => {
|
|
331
|
+
const verdict = table[name](...args);
|
|
332
|
+
if (verdict.pass !== negated) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
const message = negated ? verdict.negatedFailure() : verdict.failure();
|
|
336
|
+
throw new AssertionError(message, name, verdict.expected ?? "", verdict.received ?? render(received));
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
|
|
340
|
+
return bound;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The `.resolves` / `.rejects` surface: settle the promise, then apply the
|
|
345
|
+
* same matcher to what came out.
|
|
346
|
+
*/
|
|
347
|
+
function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean): $FlowFixMe {
|
|
348
|
+
const bound: $FlowFixMe = {};
|
|
349
|
+
for (const name of Object.keys(verdicts(undefined))) {
|
|
350
|
+
bound[name] = async (...args: $ReadOnlyArray<mixed>) => {
|
|
351
|
+
let value: mixed;
|
|
352
|
+
let rejected = false;
|
|
353
|
+
try {
|
|
354
|
+
value = await (promise: $FlowFixMe);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
rejected = true;
|
|
357
|
+
value = error;
|
|
358
|
+
}
|
|
359
|
+
if (wanted === "resolve" && rejected) {
|
|
360
|
+
throw new AssertionError(
|
|
361
|
+
`expected the promise to resolve, but it rejected with ${describeThrown(value)}`,
|
|
362
|
+
name,
|
|
363
|
+
"a resolved promise",
|
|
364
|
+
describeThrown(value),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (wanted === "reject" && !rejected) {
|
|
368
|
+
throw new AssertionError(
|
|
369
|
+
`expected the promise to reject, but it resolved with ${render(value)}`,
|
|
370
|
+
name,
|
|
371
|
+
"a rejected promise",
|
|
372
|
+
render(value),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
// `toThrow` reads a function and calls it, but a settled promise has
|
|
376
|
+
// already produced its reason. Handing the matcher a thunk that throws
|
|
377
|
+
// that reason is what makes `.rejects.toThrow(/nope/)` mean what it
|
|
378
|
+
// plainly says, with one matcher rather than two.
|
|
379
|
+
const subject =
|
|
380
|
+
name === "toThrow" && typeof value !== "function"
|
|
381
|
+
? () => {
|
|
382
|
+
throw value;
|
|
383
|
+
}
|
|
384
|
+
: value;
|
|
385
|
+
bind(subject, negated)[name](...args);
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
Object.defineProperty(bound, "not", { get: () => settled(promise, wanted, !negated) });
|
|
389
|
+
return bound;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Assert on `received`.
|
|
394
|
+
*
|
|
395
|
+
* ```js
|
|
396
|
+
* expect(sum(2, 2)).toBe(4);
|
|
397
|
+
* expect(user).toMatchObject({ name: "ada" });
|
|
398
|
+
* expect(() => parse("")).toThrow(/empty/);
|
|
399
|
+
* await expect(load()).resolves.toHaveLength(3);
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
export function expect(received: mixed): $FlowFixMe {
|
|
403
|
+
const expectation: $FlowFixMe = bind(received, false);
|
|
404
|
+
Object.defineProperty(expectation, "resolves", { get: () => settled(received, "resolve", false) });
|
|
405
|
+
Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
|
|
406
|
+
return expectation;
|
|
407
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Reading positions out of a stack trace.
|
|
4
|
+
//
|
|
5
|
+
// Two things need this: registration, which records where `it(` was written,
|
|
6
|
+
// and failure reporting, which records where the assertion was. Both want the
|
|
7
|
+
// same answer — the first frame that belongs to the person's code — so both
|
|
8
|
+
// ask here.
|
|
9
|
+
//
|
|
10
|
+
// The parsing is by hand rather than by regular expression. A frame's tail is
|
|
11
|
+
// `…:<line>:<column>` with an optional `)`, and scanning backwards for that is
|
|
12
|
+
// shorter to read than the pattern that matches it, exact about what it
|
|
13
|
+
// accepts, and cannot be surprised by a path containing something
|
|
14
|
+
// regex-shaped.
|
|
15
|
+
|
|
16
|
+
/** A position in a source file, one-based line and column. */
|
|
17
|
+
export type Site = {| +line: number, +column: number |};
|
|
18
|
+
|
|
19
|
+
/** Frames belonging to the runner itself, which no test author wrote. */
|
|
20
|
+
const INTERNAL_MARKERS = [
|
|
21
|
+
"/packages/test/internal/",
|
|
22
|
+
"/packages/test/worker.js",
|
|
23
|
+
"/@uniflowed/test/",
|
|
24
|
+
"node:internal/",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Whether `frame` is the runner's own rather than the caller's. */
|
|
28
|
+
export function isInternalFrame(frame: string): boolean {
|
|
29
|
+
return INTERNAL_MARKERS.some((marker) => frame.includes(marker));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `:line:column` a stack frame ends with, or `null`.
|
|
34
|
+
*
|
|
35
|
+
* A frame ends either `…:12:34` or `…:12:34)`. Anything else — a native
|
|
36
|
+
* frame, a bare function name — has no position, and saying so is better than
|
|
37
|
+
* inventing line one.
|
|
38
|
+
*/
|
|
39
|
+
export function frameSite(frame: string): Site | null {
|
|
40
|
+
let end = frame.length;
|
|
41
|
+
while (end > 0 && (frame[end - 1] === " " || frame[end - 1] === ")")) {
|
|
42
|
+
end -= 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const column = digitsBefore(frame, end);
|
|
46
|
+
if (column == null || column.start === 0 || frame[column.start - 1] !== ":") {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const line = digitsBefore(frame, column.start - 1);
|
|
50
|
+
if (line == null || line.start === 0 || frame[line.start - 1] !== ":") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return { line: line.value, column: column.value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The run of digits ending at `end`, with where it starts. */
|
|
57
|
+
function digitsBefore(text: string, end: number): {| +value: number, +start: number |} | null {
|
|
58
|
+
let start = end;
|
|
59
|
+
while (start > 0 && text[start - 1] >= "0" && text[start - 1] <= "9") {
|
|
60
|
+
start -= 1;
|
|
61
|
+
}
|
|
62
|
+
if (start === end) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return { value: Number(text.slice(start, end)), start };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The first position in `stack` outside the runner, or `null`.
|
|
70
|
+
*
|
|
71
|
+
* `skipInternal` is false when the caller has already trimmed the runner's own
|
|
72
|
+
* frames and wants the first frame whatever it is.
|
|
73
|
+
*/
|
|
74
|
+
export function firstUserSite(stack: string | null | void, skipInternal: boolean = true): Site | null {
|
|
75
|
+
if (stack == null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
for (const frame of stack.split("\n").slice(1)) {
|
|
79
|
+
if (skipInternal && isInternalFrame(frame)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const site = frameSite(frame);
|
|
83
|
+
if (site != null) {
|
|
84
|
+
return site;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `stack` with the runner's own frames removed.
|
|
92
|
+
*
|
|
93
|
+
* A stack that starts inside the matcher buries the one line that matters
|
|
94
|
+
* under eight that never do. The message stays as the first line, so the
|
|
95
|
+
* result still reads as a trace.
|
|
96
|
+
*/
|
|
97
|
+
export function userFrames(stack: string | null | void): string | null {
|
|
98
|
+
if (stack == null) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const lines = stack.split("\n");
|
|
102
|
+
const head = lines[0] ?? "";
|
|
103
|
+
const frames = lines.slice(1).filter((frame) => !isInternalFrame(frame));
|
|
104
|
+
return frames.length === 0 ? head : [head, ...frames].join("\n");
|
|
105
|
+
}
|