@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.5

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.
@@ -12,6 +12,10 @@
12
12
  // matcher table to what came out, so `await expect(p).resolves.toBe(1)` reads
13
13
  // the way the synchronous form does.
14
14
 
15
+ import type { SpyCall } from "./spy.js";
16
+ import * as asymmetric from "./asymmetric.js";
17
+ import * as snapshot from "./snapshot.js";
18
+ import { isSpy } from "./spy.js";
15
19
  import { equals, matchesObject, render } from "./equality.js";
16
20
 
17
21
  /** Thrown when a matcher does not hold. */
@@ -34,78 +38,26 @@ export class AssertionError extends Error {
34
38
 
35
39
  /** What a matcher decided, and how to say it either way. */
36
40
  type Verdict = {|
37
- +pass: boolean,
38
- +failure: () => string,
39
- +negatedFailure: () => string,
40
- +expected?: string,
41
- +received?: string,
41
+ readonly pass: boolean,
42
+ readonly failure: () => string,
43
+ readonly negatedFailure: () => string,
44
+ readonly expected?: string,
45
+ readonly received?: string,
42
46
  |};
43
47
 
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 |} {
48
+ function propertyAt(
49
+ value: mixed,
50
+ path: string,
51
+ ): {| readonly found: boolean, readonly value: mixed |} {
100
52
  let current = value;
101
53
  for (const key of path.split(".")) {
102
54
  if (current == null) {
103
55
  return { found: false, value: undefined };
104
56
  }
105
- if (!Object.prototype.hasOwnProperty.call((current: $FlowFixMe), key)) {
57
+ if (!Object.prototype.hasOwnProperty.call(current as $FlowFixMe, key)) {
106
58
  return { found: false, value: undefined };
107
59
  }
108
- current = (current: $FlowFixMe)[key];
60
+ current = (current as $FlowFixMe)[key];
109
61
  }
110
62
  return { found: true, value: current };
111
63
  }
@@ -126,7 +78,7 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
126
78
  return message === expected.message;
127
79
  }
128
80
  if (typeof expected === "function") {
129
- return thrown instanceof (expected: $FlowFixMe);
81
+ return thrown instanceof (expected as $FlowFixMe);
130
82
  }
131
83
  return equals(thrown, expected);
132
84
  }
@@ -136,8 +88,28 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
136
88
  *
137
89
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
138
90
  * `.not` reuse all of them.
91
+ *
92
+ * # The `any` in the indexer
93
+ *
94
+ * The entries do not agree about their arguments — `toBe` takes a `mixed`,
95
+ * `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
96
+ * applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
97
+ * collected from a caller. Parameters are contravariant, so one indexer cannot
98
+ * describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
99
+ * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
100
+ * entry and rejects the call.
101
+ *
102
+ * `mixed` with a cast at the call would move the same unsoundness one line
103
+ * without checking anything, because the caller is `bind`, whose result is
104
+ * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
+ * type that makes any of this checked is a written-out matcher interface —
106
+ * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
+ * is what `expect`'s own annotation is waiting for. Until that exists, a
108
+ * narrower type here would be precision nobody can reach.
139
109
  */
140
- function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>) => Verdict } {
110
+ function verdicts(received: mixed): {
111
+ readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
112
+ } {
141
113
  const shown = () => render(received);
142
114
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
143
115
  pass,
@@ -146,7 +118,8 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
146
118
  failure: () => `expected ${shown()} ${what}`,
147
119
  negatedFailure: () => `expected ${shown()} not ${what}`,
148
120
  });
149
- const spyCalls = (): Array<SpyCall> => (isSpy(received) ? (received: $FlowFixMe).mock.calls : []);
121
+ const spyCalls = (): Array<SpyCall> =>
122
+ isSpy(received) ? (received as $FlowFixMe).mock.calls : [];
150
123
  const requireSpy = (matcher: string) => {
151
124
  if (!isSpy(received)) {
152
125
  throw new AssertionError(
@@ -187,17 +160,33 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
187
160
  toBeDefined: () => simple(received !== undefined, "to be defined"),
188
161
  toBeNaN: () => simple(typeof received === "number" && Number.isNaN(received), "to be NaN"),
189
162
  toBeGreaterThan: (expected: mixed) =>
190
- simple((received: $FlowFixMe) > (expected: $FlowFixMe), `to be greater than ${render(expected)}`, expected),
163
+ simple(
164
+ (received as $FlowFixMe) > (expected as $FlowFixMe),
165
+ `to be greater than ${render(expected)}`,
166
+ expected,
167
+ ),
191
168
  toBeGreaterThanOrEqual: (expected: mixed) =>
192
- simple((received: $FlowFixMe) >= (expected: $FlowFixMe), `to be at least ${render(expected)}`, expected),
169
+ simple(
170
+ (received as $FlowFixMe) >= (expected as $FlowFixMe),
171
+ `to be at least ${render(expected)}`,
172
+ expected,
173
+ ),
193
174
  toBeLessThan: (expected: mixed) =>
194
- simple((received: $FlowFixMe) < (expected: $FlowFixMe), `to be less than ${render(expected)}`, expected),
175
+ simple(
176
+ (received as $FlowFixMe) < (expected as $FlowFixMe),
177
+ `to be less than ${render(expected)}`,
178
+ expected,
179
+ ),
195
180
  toBeLessThanOrEqual: (expected: mixed) =>
196
- simple((received: $FlowFixMe) <= (expected: $FlowFixMe), `to be at most ${render(expected)}`, expected),
181
+ simple(
182
+ (received as $FlowFixMe) <= (expected as $FlowFixMe),
183
+ `to be at most ${render(expected)}`,
184
+ expected,
185
+ ),
197
186
  toBeCloseTo: (expected: number, digits?: number) => {
198
187
  const places = digits ?? 2;
199
188
  const tolerance = 10 ** -places / 2;
200
- const difference = Math.abs((received: $FlowFixMe) - expected);
189
+ const difference = Math.abs((received as $FlowFixMe) - expected);
201
190
  return simple(
202
191
  difference < tolerance,
203
192
  `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
@@ -216,7 +205,11 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
216
205
  return simple(pass, `to contain ${render(expected)}`, expected);
217
206
  },
218
207
  toContainEqual: (expected: mixed) => {
219
- const items = Array.isArray(received) ? received : received instanceof Set ? [...received] : [];
208
+ const items = Array.isArray(received)
209
+ ? received
210
+ : received instanceof Set
211
+ ? [...received]
212
+ : [];
220
213
  return simple(
221
214
  items.some((item) => equals(item, expected)),
222
215
  `to contain something equal to ${render(expected)}`,
@@ -224,8 +217,12 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
224
217
  );
225
218
  },
226
219
  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);
220
+ const length = received == null ? undefined : (received as $FlowFixMe).length;
221
+ return simple(
222
+ length === expected,
223
+ `to have length ${expected}, not ${render(length)}`,
224
+ expected,
225
+ );
229
226
  },
230
227
  toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
231
228
  const found = propertyAt(received, path);
@@ -240,21 +237,60 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
240
237
  },
241
238
  toMatch: (expected: mixed) => {
242
239
  const text = typeof received === "string" ? received : String(received);
243
- const pass = typeof expected === "string" ? text.includes(expected) : (expected: $FlowFixMe).test(text);
240
+ const pass =
241
+ typeof expected === "string"
242
+ ? text.includes(expected)
243
+ : (expected as $FlowFixMe).test(text);
244
244
  return simple(pass, `to match ${render(expected)}`, expected);
245
245
  },
246
246
  toMatchObject: (expected: mixed) =>
247
247
  simple(matchesObject(received, expected), `to match ${render(expected)}`, expected),
248
248
  toBeInstanceOf: (expected: mixed) =>
249
249
  simple(
250
- typeof expected === "function" && received instanceof (expected: $FlowFixMe),
250
+ typeof expected === "function" && received instanceof (expected as $FlowFixMe),
251
251
  `to be an instance of ${render(expected)}`,
252
252
  expected,
253
253
  ),
254
254
  toBeTypeOf: (expected: string) =>
255
- simple(typeof received === expected, `to be of type ${expected}, not ${typeof received}`, expected),
255
+ simple(
256
+ typeof received === expected,
257
+ `to be of type ${expected}, not ${typeof received}`,
258
+ expected,
259
+ ),
256
260
  toSatisfy: (predicate: (value: mixed) => boolean) =>
257
261
  simple(predicate(received) === true, "to satisfy the predicate"),
262
+ toMatchSnapshot: (hint?: string): Verdict => {
263
+ const verdict = snapshot.matchSnapshot(received, hint);
264
+ return {
265
+ pass: verdict.pass,
266
+ expected: verdict.expected ?? "(no snapshot yet)",
267
+ received: verdict.received,
268
+ // The whole of both sides, because a mismatch that says only "the
269
+ // snapshot did not match" makes a reader open two files.
270
+ failure: () =>
271
+ `snapshot did not match.\n\nstored:\n${verdict.expected ?? "(none)"}\n\n` +
272
+ `received:\n${verdict.received}\n\n` +
273
+ "Run `uf test -u` if the new value is the right one.",
274
+ negatedFailure: () => "expected the value not to match its snapshot",
275
+ };
276
+ },
277
+ toMatchInlineSnapshot: (expected?: string): Verdict => {
278
+ const verdict = snapshot.matchInlineSnapshot(received, expected);
279
+ return {
280
+ pass: verdict.pass,
281
+ expected: verdict.expected ?? "(no inline snapshot yet)",
282
+ received: verdict.received,
283
+ failure: () =>
284
+ verdict.expected == null
285
+ ? // uf does not rewrite a test file — a tool that edits the file you
286
+ // are editing is a tool that loses work — so it reports what to
287
+ // paste in and leaves the decision to a person.
288
+ `no inline snapshot yet. Paste this into the call:\n\n\`\`\`\n${verdict.received}\n\`\`\``
289
+ : `inline snapshot did not match.\n\nstored:\n${verdict.expected}\n\n` +
290
+ `received:\n${verdict.received}`,
291
+ negatedFailure: () => "expected the value not to match its inline snapshot",
292
+ };
293
+ },
258
294
  toThrow: (...rest: $ReadOnlyArray<mixed>) => {
259
295
  const expected = rest[0];
260
296
  if (typeof received !== "function") {
@@ -314,7 +350,157 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
314
350
  args,
315
351
  );
316
352
  },
353
+
354
+ // ---------------------------------------------------------------- //
355
+ // Elements
356
+ //
357
+ // These read properties of whatever they are given, so this module still
358
+ // needs no DOM and no dependency on one: an element is an object with a
359
+ // `tagName`, and a process without a document simply never has one to
360
+ // pass in. `element` says so when it does not.
361
+ // ---------------------------------------------------------------- //
362
+
363
+ toBeInTheDocument: () => {
364
+ const node = element("toBeInTheDocument");
365
+ const root = node.ownerDocument;
366
+ return simple(root != null && root.contains(node), "to be in the document");
367
+ },
368
+ toBeVisible: () => {
369
+ const node = element("toBeVisible");
370
+ return simple(isVisible(node), "to be visible");
371
+ },
372
+ toBeDisabled: () => {
373
+ const node = element("toBeDisabled");
374
+ return simple(isDisabled(node), "to be disabled");
375
+ },
376
+ toBeEnabled: () => {
377
+ const node = element("toBeEnabled");
378
+ return simple(!isDisabled(node), "to be enabled");
379
+ },
380
+ toBeChecked: () => {
381
+ const node = element("toBeChecked");
382
+ const aria = node.getAttribute("aria-checked");
383
+ const checked = aria != null ? aria === "true" : (node as $FlowFixMe).checked === true;
384
+ return simple(checked, "to be checked");
385
+ },
386
+ toBeRequired: () => {
387
+ const node = element("toBeRequired");
388
+ return simple(
389
+ (node as $FlowFixMe).required === true || node.getAttribute("aria-required") === "true",
390
+ "to be required",
391
+ );
392
+ },
393
+ toHaveFocus: () => {
394
+ const node = element("toHaveFocus");
395
+ return simple(node.ownerDocument?.activeElement === node, "to have focus");
396
+ },
397
+ toHaveAttribute: (name: mixed, value?: mixed) => {
398
+ const node = element("toHaveAttribute");
399
+ const actual = node.getAttribute(String(name));
400
+ if (value === undefined) {
401
+ return simple(actual != null, `to have the attribute ${render(name)}`, name);
402
+ }
403
+ return {
404
+ pass: actual === String(value),
405
+ expected: render(value),
406
+ received: render(actual),
407
+ failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
408
+ };
409
+ },
410
+ toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
411
+ const node = element("toHaveClass");
412
+ const classes = (node.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
413
+ const wanted = names.map(String);
414
+ return {
415
+ pass: wanted.every((name) => classes.includes(name)),
416
+ expected: render(wanted),
417
+ received: render(classes),
418
+ failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
419
+ };
420
+ },
421
+ toHaveTextContent: (expected: mixed) => {
422
+ const node = element("toHaveTextContent");
423
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
424
+ const pass =
425
+ expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
426
+ return {
427
+ pass,
428
+ expected: render(expected),
429
+ received: render(text),
430
+ failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
431
+ };
432
+ },
433
+ toHaveValue: (expected: mixed) => {
434
+ const node = element("toHaveValue");
435
+ const actual = (node as $FlowFixMe).value;
436
+ return {
437
+ pass: equals(actual, expected),
438
+ expected: render(expected),
439
+ received: render(actual),
440
+ failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
441
+ };
442
+ },
317
443
  };
444
+
445
+ /**
446
+ * The received value as an element, or a failure that says what it was.
447
+ *
448
+ * An element matcher applied to a string is almost always a query whose
449
+ * result was used without being awaited, and "received a Promise" is a much
450
+ * better message than a `TypeError` about `getAttribute`.
451
+ */
452
+ function element(matcher: string): Element {
453
+ const node: $FlowFixMe = received;
454
+ if (node == null || typeof node.getAttribute !== "function") {
455
+ throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
456
+ }
457
+ return node;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Whether a reader would see this element.
463
+ *
464
+ * Walks the ancestors, because `display: none` on a parent hides a child whose
465
+ * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
466
+ * open each hide their subtree too.
467
+ */
468
+ function isVisible(node: Element): boolean {
469
+ let current: $FlowFixMe = node;
470
+ while (current != null && current.nodeType === 1) {
471
+ if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
472
+ return false;
473
+ }
474
+ if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
475
+ return false;
476
+ }
477
+ const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
478
+ if (style != null) {
479
+ if (style.display === "none" || style.visibility === "hidden") {
480
+ return false;
481
+ }
482
+ if (style.opacity === "0") {
483
+ return false;
484
+ }
485
+ }
486
+ current = current.parentElement;
487
+ }
488
+ return true;
489
+ }
490
+
491
+ /** Whether the control is disabled, by its own attribute or a fieldset's. */
492
+ function isDisabled(node: Element): boolean {
493
+ let current: $FlowFixMe = node;
494
+ while (current != null && current.nodeType === 1) {
495
+ if (current.hasAttribute("disabled")) {
496
+ return true;
497
+ }
498
+ if (current.getAttribute("aria-disabled") === "true") {
499
+ return true;
500
+ }
501
+ current = current.parentElement;
502
+ }
503
+ return false;
318
504
  }
319
505
 
320
506
  /**
@@ -333,7 +519,12 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
333
519
  return undefined;
334
520
  }
335
521
  const message = negated ? verdict.negatedFailure() : verdict.failure();
336
- throw new AssertionError(message, name, verdict.expected ?? "", verdict.received ?? render(received));
522
+ throw new AssertionError(
523
+ message,
524
+ name,
525
+ verdict.expected ?? "",
526
+ verdict.received ?? render(received),
527
+ );
337
528
  };
338
529
  }
339
530
  Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
@@ -351,7 +542,7 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
351
542
  let value: mixed;
352
543
  let rejected = false;
353
544
  try {
354
- value = await (promise: $FlowFixMe);
545
+ value = await (promise as $FlowFixMe);
355
546
  } catch (error) {
356
547
  rejected = true;
357
548
  value = error;
@@ -399,9 +590,49 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
399
590
  * await expect(load()).resolves.toHaveLength(3);
400
591
  * ```
401
592
  */
402
- export function expect(received: mixed): $FlowFixMe {
593
+ function expectValue(received: mixed): $FlowFixMe {
403
594
  const expectation: $FlowFixMe = bind(received, false);
404
- Object.defineProperty(expectation, "resolves", { get: () => settled(received, "resolve", false) });
595
+ Object.defineProperty(expectation, "resolves", {
596
+ get: () => settled(received, "resolve", false),
597
+ });
405
598
  Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
406
599
  return expectation;
407
600
  }
601
+
602
+ /**
603
+ * Assert about a value.
604
+ *
605
+ * Declared with its matchers attached rather than assigned afterwards: a
606
+ * shipped module may only declare, import and export at its top level, and
607
+ * `expect.any = …` is a statement that runs when the module is imported.
608
+ *
609
+ * The `expect.*` half are the matchers that stand in for a value instead of
610
+ * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
611
+ * says what a test means; spelling out the id would either be a lie or a second
612
+ * source of truth. They work at any depth, because `equals` asks every value it
613
+ * meets whether it is one.
614
+ *
615
+ * `expect.not.*` is the negated form, spelled the way Jest and Vitest spell it
616
+ * — `expect.not.objectContaining({ error: expect.anything() })` reads better
617
+ * than a negated assertion around the whole object, and is the form a suite
618
+ * being ported will already have.
619
+ */
620
+ export const expect: $FlowFixMe = Object.assign(expectValue, {
621
+ any: asymmetric.any,
622
+ anything: asymmetric.anything,
623
+ objectContaining: asymmetric.objectContaining,
624
+ arrayContaining: asymmetric.arrayContaining,
625
+ stringContaining: asymmetric.stringContaining,
626
+ stringMatching: asymmetric.stringMatching,
627
+ closeTo: asymmetric.closeTo,
628
+ not: {
629
+ objectContaining: (expected: interface {}) =>
630
+ asymmetric.not(asymmetric.objectContaining(expected)),
631
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
632
+ asymmetric.not(asymmetric.arrayContaining(expected)),
633
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
634
+ stringMatching: (pattern: string | RegExp) =>
635
+ asymmetric.not(asymmetric.stringMatching(pattern)),
636
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
637
+ },
638
+ });
@@ -14,7 +14,7 @@
14
14
  // regex-shaped.
15
15
 
16
16
  /** A position in a source file, one-based line and column. */
17
- export type Site = {| +line: number, +column: number |};
17
+ export type Site = {| readonly line: number, readonly column: number |};
18
18
 
19
19
  /** Frames belonging to the runner itself, which no test author wrote. */
20
20
  const INTERNAL_MARKERS = [
@@ -54,7 +54,10 @@ export function frameSite(frame: string): Site | null {
54
54
  }
55
55
 
56
56
  /** The run of digits ending at `end`, with where it starts. */
57
- function digitsBefore(text: string, end: number): {| +value: number, +start: number |} | null {
57
+ function digitsBefore(
58
+ text: string,
59
+ end: number,
60
+ ): {| readonly value: number, readonly start: number |} | null {
58
61
  let start = end;
59
62
  while (start > 0 && text[start - 1] >= "0" && text[start - 1] <= "9") {
60
63
  start -= 1;
@@ -71,7 +74,10 @@ function digitsBefore(text: string, end: number): {| +value: number, +start: num
71
74
  * `skipInternal` is false when the caller has already trimmed the runner's own
72
75
  * frames and wants the first frame whatever it is.
73
76
  */
74
- export function firstUserSite(stack: string | null | void, skipInternal: boolean = true): Site | null {
77
+ export function firstUserSite(
78
+ stack: string | null | void,
79
+ skipInternal: boolean = true,
80
+ ): Site | null {
75
81
  if (stack == null) {
76
82
  return null;
77
83
  }