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

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,30 @@ 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, and is
108
+ * ubugeeei-prod/uf#402. Until that exists, a narrower type here would be
109
+ * precision nobody can reach.
139
110
  */
140
- function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>) => Verdict } {
111
+ function verdicts(received: mixed): {
112
+ // uf-lint-disable-next-line flow/unclear-type
113
+ readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
114
+ } {
141
115
  const shown = () => render(received);
142
116
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
143
117
  pass,
@@ -146,7 +120,8 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
146
120
  failure: () => `expected ${shown()} ${what}`,
147
121
  negatedFailure: () => `expected ${shown()} not ${what}`,
148
122
  });
149
- const spyCalls = (): Array<SpyCall> => (isSpy(received) ? (received: $FlowFixMe).mock.calls : []);
123
+ const spyCalls = (): Array<SpyCall> =>
124
+ isSpy(received) ? (received as $FlowFixMe).mock.calls : [];
150
125
  const requireSpy = (matcher: string) => {
151
126
  if (!isSpy(received)) {
152
127
  throw new AssertionError(
@@ -187,17 +162,33 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
187
162
  toBeDefined: () => simple(received !== undefined, "to be defined"),
188
163
  toBeNaN: () => simple(typeof received === "number" && Number.isNaN(received), "to be NaN"),
189
164
  toBeGreaterThan: (expected: mixed) =>
190
- simple((received: $FlowFixMe) > (expected: $FlowFixMe), `to be greater than ${render(expected)}`, expected),
165
+ simple(
166
+ (received as $FlowFixMe) > (expected as $FlowFixMe),
167
+ `to be greater than ${render(expected)}`,
168
+ expected,
169
+ ),
191
170
  toBeGreaterThanOrEqual: (expected: mixed) =>
192
- simple((received: $FlowFixMe) >= (expected: $FlowFixMe), `to be at least ${render(expected)}`, expected),
171
+ simple(
172
+ (received as $FlowFixMe) >= (expected as $FlowFixMe),
173
+ `to be at least ${render(expected)}`,
174
+ expected,
175
+ ),
193
176
  toBeLessThan: (expected: mixed) =>
194
- simple((received: $FlowFixMe) < (expected: $FlowFixMe), `to be less than ${render(expected)}`, expected),
177
+ simple(
178
+ (received as $FlowFixMe) < (expected as $FlowFixMe),
179
+ `to be less than ${render(expected)}`,
180
+ expected,
181
+ ),
195
182
  toBeLessThanOrEqual: (expected: mixed) =>
196
- simple((received: $FlowFixMe) <= (expected: $FlowFixMe), `to be at most ${render(expected)}`, expected),
183
+ simple(
184
+ (received as $FlowFixMe) <= (expected as $FlowFixMe),
185
+ `to be at most ${render(expected)}`,
186
+ expected,
187
+ ),
197
188
  toBeCloseTo: (expected: number, digits?: number) => {
198
189
  const places = digits ?? 2;
199
190
  const tolerance = 10 ** -places / 2;
200
- const difference = Math.abs((received: $FlowFixMe) - expected);
191
+ const difference = Math.abs((received as $FlowFixMe) - expected);
201
192
  return simple(
202
193
  difference < tolerance,
203
194
  `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
@@ -216,7 +207,11 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
216
207
  return simple(pass, `to contain ${render(expected)}`, expected);
217
208
  },
218
209
  toContainEqual: (expected: mixed) => {
219
- const items = Array.isArray(received) ? received : received instanceof Set ? [...received] : [];
210
+ const items = Array.isArray(received)
211
+ ? received
212
+ : received instanceof Set
213
+ ? [...received]
214
+ : [];
220
215
  return simple(
221
216
  items.some((item) => equals(item, expected)),
222
217
  `to contain something equal to ${render(expected)}`,
@@ -224,8 +219,12 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
224
219
  );
225
220
  },
226
221
  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);
222
+ const length = received == null ? undefined : (received as $FlowFixMe).length;
223
+ return simple(
224
+ length === expected,
225
+ `to have length ${expected}, not ${render(length)}`,
226
+ expected,
227
+ );
229
228
  },
230
229
  toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
231
230
  const found = propertyAt(received, path);
@@ -240,21 +239,60 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
240
239
  },
241
240
  toMatch: (expected: mixed) => {
242
241
  const text = typeof received === "string" ? received : String(received);
243
- const pass = typeof expected === "string" ? text.includes(expected) : (expected: $FlowFixMe).test(text);
242
+ const pass =
243
+ typeof expected === "string"
244
+ ? text.includes(expected)
245
+ : (expected as $FlowFixMe).test(text);
244
246
  return simple(pass, `to match ${render(expected)}`, expected);
245
247
  },
246
248
  toMatchObject: (expected: mixed) =>
247
249
  simple(matchesObject(received, expected), `to match ${render(expected)}`, expected),
248
250
  toBeInstanceOf: (expected: mixed) =>
249
251
  simple(
250
- typeof expected === "function" && received instanceof (expected: $FlowFixMe),
252
+ typeof expected === "function" && received instanceof (expected as $FlowFixMe),
251
253
  `to be an instance of ${render(expected)}`,
252
254
  expected,
253
255
  ),
254
256
  toBeTypeOf: (expected: string) =>
255
- simple(typeof received === expected, `to be of type ${expected}, not ${typeof received}`, expected),
257
+ simple(
258
+ typeof received === expected,
259
+ `to be of type ${expected}, not ${typeof received}`,
260
+ expected,
261
+ ),
256
262
  toSatisfy: (predicate: (value: mixed) => boolean) =>
257
263
  simple(predicate(received) === true, "to satisfy the predicate"),
264
+ toMatchSnapshot: (hint?: string): Verdict => {
265
+ const verdict = snapshot.matchSnapshot(received, hint);
266
+ return {
267
+ pass: verdict.pass,
268
+ expected: verdict.expected ?? "(no snapshot yet)",
269
+ received: verdict.received,
270
+ // The whole of both sides, because a mismatch that says only "the
271
+ // snapshot did not match" makes a reader open two files.
272
+ failure: () =>
273
+ `snapshot did not match.\n\nstored:\n${verdict.expected ?? "(none)"}\n\n` +
274
+ `received:\n${verdict.received}\n\n` +
275
+ "Run `uf test -u` if the new value is the right one.",
276
+ negatedFailure: () => "expected the value not to match its snapshot",
277
+ };
278
+ },
279
+ toMatchInlineSnapshot: (expected?: string): Verdict => {
280
+ const verdict = snapshot.matchInlineSnapshot(received, expected);
281
+ return {
282
+ pass: verdict.pass,
283
+ expected: verdict.expected ?? "(no inline snapshot yet)",
284
+ received: verdict.received,
285
+ failure: () =>
286
+ verdict.expected == null
287
+ ? // uf does not rewrite a test file — a tool that edits the file you
288
+ // are editing is a tool that loses work — so it reports what to
289
+ // paste in and leaves the decision to a person.
290
+ `no inline snapshot yet. Paste this into the call:\n\n\`\`\`\n${verdict.received}\n\`\`\``
291
+ : `inline snapshot did not match.\n\nstored:\n${verdict.expected}\n\n` +
292
+ `received:\n${verdict.received}`,
293
+ negatedFailure: () => "expected the value not to match its inline snapshot",
294
+ };
295
+ },
258
296
  toThrow: (...rest: $ReadOnlyArray<mixed>) => {
259
297
  const expected = rest[0];
260
298
  if (typeof received !== "function") {
@@ -314,7 +352,157 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
314
352
  args,
315
353
  );
316
354
  },
355
+
356
+ // ---------------------------------------------------------------- //
357
+ // Elements
358
+ //
359
+ // These read properties of whatever they are given, so this module still
360
+ // needs no DOM and no dependency on one: an element is an object with a
361
+ // `tagName`, and a process without a document simply never has one to
362
+ // pass in. `element` says so when it does not.
363
+ // ---------------------------------------------------------------- //
364
+
365
+ toBeInTheDocument: () => {
366
+ const node = element("toBeInTheDocument");
367
+ const root = node.ownerDocument;
368
+ return simple(root != null && root.contains(node), "to be in the document");
369
+ },
370
+ toBeVisible: () => {
371
+ const node = element("toBeVisible");
372
+ return simple(isVisible(node), "to be visible");
373
+ },
374
+ toBeDisabled: () => {
375
+ const node = element("toBeDisabled");
376
+ return simple(isDisabled(node), "to be disabled");
377
+ },
378
+ toBeEnabled: () => {
379
+ const node = element("toBeEnabled");
380
+ return simple(!isDisabled(node), "to be enabled");
381
+ },
382
+ toBeChecked: () => {
383
+ const node = element("toBeChecked");
384
+ const aria = node.getAttribute("aria-checked");
385
+ const checked = aria != null ? aria === "true" : (node as $FlowFixMe).checked === true;
386
+ return simple(checked, "to be checked");
387
+ },
388
+ toBeRequired: () => {
389
+ const node = element("toBeRequired");
390
+ return simple(
391
+ (node as $FlowFixMe).required === true || node.getAttribute("aria-required") === "true",
392
+ "to be required",
393
+ );
394
+ },
395
+ toHaveFocus: () => {
396
+ const node = element("toHaveFocus");
397
+ return simple(node.ownerDocument?.activeElement === node, "to have focus");
398
+ },
399
+ toHaveAttribute: (name: mixed, value?: mixed) => {
400
+ const node = element("toHaveAttribute");
401
+ const actual = node.getAttribute(String(name));
402
+ if (value === undefined) {
403
+ return simple(actual != null, `to have the attribute ${render(name)}`, name);
404
+ }
405
+ return {
406
+ pass: actual === String(value),
407
+ expected: render(value),
408
+ received: render(actual),
409
+ failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
410
+ };
411
+ },
412
+ toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
413
+ const node = element("toHaveClass");
414
+ const classes = (node.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
415
+ const wanted = names.map(String);
416
+ return {
417
+ pass: wanted.every((name) => classes.includes(name)),
418
+ expected: render(wanted),
419
+ received: render(classes),
420
+ failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
421
+ };
422
+ },
423
+ toHaveTextContent: (expected: mixed) => {
424
+ const node = element("toHaveTextContent");
425
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
426
+ const pass =
427
+ expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
428
+ return {
429
+ pass,
430
+ expected: render(expected),
431
+ received: render(text),
432
+ failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
433
+ };
434
+ },
435
+ toHaveValue: (expected: mixed) => {
436
+ const node = element("toHaveValue");
437
+ const actual = (node as $FlowFixMe).value;
438
+ return {
439
+ pass: equals(actual, expected),
440
+ expected: render(expected),
441
+ received: render(actual),
442
+ failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
443
+ };
444
+ },
317
445
  };
446
+
447
+ /**
448
+ * The received value as an element, or a failure that says what it was.
449
+ *
450
+ * An element matcher applied to a string is almost always a query whose
451
+ * result was used without being awaited, and "received a Promise" is a much
452
+ * better message than a `TypeError` about `getAttribute`.
453
+ */
454
+ function element(matcher: string): Element {
455
+ const node: $FlowFixMe = received;
456
+ if (node == null || typeof node.getAttribute !== "function") {
457
+ throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
458
+ }
459
+ return node;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Whether a reader would see this element.
465
+ *
466
+ * Walks the ancestors, because `display: none` on a parent hides a child whose
467
+ * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
468
+ * open each hide their subtree too.
469
+ */
470
+ function isVisible(node: Element): boolean {
471
+ let current: $FlowFixMe = node;
472
+ while (current != null && current.nodeType === 1) {
473
+ if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
474
+ return false;
475
+ }
476
+ if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
477
+ return false;
478
+ }
479
+ const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
480
+ if (style != null) {
481
+ if (style.display === "none" || style.visibility === "hidden") {
482
+ return false;
483
+ }
484
+ if (style.opacity === "0") {
485
+ return false;
486
+ }
487
+ }
488
+ current = current.parentElement;
489
+ }
490
+ return true;
491
+ }
492
+
493
+ /** Whether the control is disabled, by its own attribute or a fieldset's. */
494
+ function isDisabled(node: Element): boolean {
495
+ let current: $FlowFixMe = node;
496
+ while (current != null && current.nodeType === 1) {
497
+ if (current.hasAttribute("disabled")) {
498
+ return true;
499
+ }
500
+ if (current.getAttribute("aria-disabled") === "true") {
501
+ return true;
502
+ }
503
+ current = current.parentElement;
504
+ }
505
+ return false;
318
506
  }
319
507
 
320
508
  /**
@@ -333,7 +521,12 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
333
521
  return undefined;
334
522
  }
335
523
  const message = negated ? verdict.negatedFailure() : verdict.failure();
336
- throw new AssertionError(message, name, verdict.expected ?? "", verdict.received ?? render(received));
524
+ throw new AssertionError(
525
+ message,
526
+ name,
527
+ verdict.expected ?? "",
528
+ verdict.received ?? render(received),
529
+ );
337
530
  };
338
531
  }
339
532
  Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
@@ -351,7 +544,7 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
351
544
  let value: mixed;
352
545
  let rejected = false;
353
546
  try {
354
- value = await (promise: $FlowFixMe);
547
+ value = await (promise as $FlowFixMe);
355
548
  } catch (error) {
356
549
  rejected = true;
357
550
  value = error;
@@ -399,9 +592,58 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
399
592
  * await expect(load()).resolves.toHaveLength(3);
400
593
  * ```
401
594
  */
402
- export function expect(received: mixed): $FlowFixMe {
595
+ function expectValue(received: mixed): $FlowFixMe {
403
596
  const expectation: $FlowFixMe = bind(received, false);
404
- Object.defineProperty(expectation, "resolves", { get: () => settled(received, "resolve", false) });
597
+ Object.defineProperty(expectation, "resolves", {
598
+ get: () => settled(received, "resolve", false),
599
+ });
405
600
  Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
406
601
  return expectation;
407
602
  }
603
+
604
+ /**
605
+ * Assert about a value.
606
+ *
607
+ * Declared with its matchers attached rather than assigned afterwards: a
608
+ * shipped module may only declare, import and export at its top level, and
609
+ * `expect.any = …` is a statement that runs when the module is imported.
610
+ *
611
+ * The `expect.*` half are the matchers that stand in for a value instead of
612
+ * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
613
+ * says what a test means; spelling out the id would either be a lie or a second
614
+ * source of truth. They work at any depth, because `equals` asks every value it
615
+ * meets whether it is one.
616
+ *
617
+ * `expect.not.*` is the negated form, spelled the way Jest and Vitest spell it
618
+ * — `expect.not.objectContaining({ error: expect.anything() })` reads better
619
+ * than a negated assertion around the whole object, and is the form a suite
620
+ * being ported will already have.
621
+ */
622
+ // `flow/unsafe-object-assign` asks for an object spread, and a spread cannot
623
+ // produce this value: `expect` is a *function* with matchers hanging off it,
624
+ // and `{ ...expectValue, ...matchers }` is a plain object that a test cannot
625
+ // call. `Object.assign` onto a callable is the only expression that makes one,
626
+ // and the alternative the rule is really warning about — `expect.any = …`
627
+ // afterwards — is the top-level statement the comment above rules out. What it
628
+ // mutates is a function this module declared six lines up and exports here; no
629
+ // object belonging to anybody else is touched.
630
+ // uf-lint-disable-next-line flow/unsafe-object-assign
631
+ export const expect: $FlowFixMe = Object.assign(expectValue, {
632
+ any: asymmetric.any,
633
+ anything: asymmetric.anything,
634
+ objectContaining: asymmetric.objectContaining,
635
+ arrayContaining: asymmetric.arrayContaining,
636
+ stringContaining: asymmetric.stringContaining,
637
+ stringMatching: asymmetric.stringMatching,
638
+ closeTo: asymmetric.closeTo,
639
+ not: {
640
+ objectContaining: (expected: interface {}) =>
641
+ asymmetric.not(asymmetric.objectContaining(expected)),
642
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
643
+ asymmetric.not(asymmetric.arrayContaining(expected)),
644
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
645
+ stringMatching: (pattern: string | RegExp) =>
646
+ asymmetric.not(asymmetric.stringMatching(pattern)),
647
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
648
+ },
649
+ });
@@ -14,13 +14,26 @@
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
- /** Frames belonging to the runner itself, which no test author wrote. */
19
+ /**
20
+ * Frames belonging to the testing libraries, which no test author wrote.
21
+ *
22
+ * `@uniflowed/react-testing` is here for exactly the reason `@uniflowed/test`
23
+ * is, and was missing because it is a different package. Every `getBy…`
24
+ * failure is *constructed* inside it, so the first surviving frame of the
25
+ * commonest failure a component test can produce was `internal/queries.js` —
26
+ * and since a site is reported as a line of the file being run, the reader was
27
+ * sent to a line their own file does not have. That is ubugeeei-prod/uf#319.
28
+ * A library that raises on the caller's behalf is the runner as far as the
29
+ * report is concerned.
30
+ */
20
31
  const INTERNAL_MARKERS = [
21
32
  "/packages/test/internal/",
22
33
  "/packages/test/worker.js",
34
+ "/packages/react-testing/",
23
35
  "/@uniflowed/test/",
36
+ "/@uniflowed/react-testing/",
24
37
  "node:internal/",
25
38
  ];
26
39
 
@@ -53,8 +66,54 @@ export function frameSite(frame: string): Site | null {
53
66
  return { line: line.value, column: column.value };
54
67
  }
55
68
 
69
+ /**
70
+ * The file a stack frame names, or `null`.
71
+ *
72
+ * The same scan as [`frameSite`], stopping one step earlier: everything before
73
+ * the `:line:column` is where the code is, and what that is depends on how V8
74
+ * wrote the frame — `at name (/path:1:2)` when it has a function name, and
75
+ * `at /path:1:2` or `at async file:///path:1:2` when it does not.
76
+ *
77
+ * What comes back is whatever the frame said, a path or a URL, because those
78
+ * are the two things it can be and a caller resolving a module specifier
79
+ * against it has to tell them apart anyway.
80
+ */
81
+ export function frameFile(frame: string): string | null {
82
+ let end = frame.length;
83
+ while (end > 0 && (frame[end - 1] === " " || frame[end - 1] === ")")) {
84
+ end -= 1;
85
+ }
86
+
87
+ const column = digitsBefore(frame, end);
88
+ if (column == null || column.start === 0 || frame[column.start - 1] !== ":") {
89
+ return null;
90
+ }
91
+ const line = digitsBefore(frame, column.start - 1);
92
+ if (line == null || line.start === 0 || frame[line.start - 1] !== ":") {
93
+ return null;
94
+ }
95
+
96
+ let text = frame.slice(0, line.start - 1);
97
+ const open = text.lastIndexOf("(");
98
+ if (open !== -1) {
99
+ text = text.slice(open + 1);
100
+ } else {
101
+ const at = text.lastIndexOf(" at ");
102
+ text = at === -1 ? text : text.slice(at + 4);
103
+ }
104
+ text = text.trim();
105
+ // `at async /path:1:2` — the marker belongs to the frame, not to the file.
106
+ if (text.startsWith("async ")) {
107
+ text = text.slice("async ".length).trim();
108
+ }
109
+ return text === "" ? null : text;
110
+ }
111
+
56
112
  /** The run of digits ending at `end`, with where it starts. */
57
- function digitsBefore(text: string, end: number): {| +value: number, +start: number |} | null {
113
+ function digitsBefore(
114
+ text: string,
115
+ end: number,
116
+ ): {| readonly value: number, readonly start: number |} | null {
58
117
  let start = end;
59
118
  while (start > 0 && text[start - 1] >= "0" && text[start - 1] <= "9") {
60
119
  start -= 1;
@@ -71,7 +130,10 @@ function digitsBefore(text: string, end: number): {| +value: number, +start: num
71
130
  * `skipInternal` is false when the caller has already trimmed the runner's own
72
131
  * frames and wants the first frame whatever it is.
73
132
  */
74
- export function firstUserSite(stack: string | null | void, skipInternal: boolean = true): Site | null {
133
+ export function firstUserSite(
134
+ stack: string | null | void,
135
+ skipInternal: boolean = true,
136
+ ): Site | null {
75
137
  if (stack == null) {
76
138
  return null;
77
139
  }
@@ -87,6 +149,36 @@ export function firstUserSite(stack: string | null | void, skipInternal: boolean
87
149
  return null;
88
150
  }
89
151
 
152
+ /**
153
+ * The first position in `stack` that is in `file`, or `null`.
154
+ *
155
+ * The reporter draws a failure's position as `path:line:column`, and it takes
156
+ * the path from the file it asked the worker to run rather than from the
157
+ * frame. So a number lifted from any other file is not a vaguer answer than
158
+ * none — it is a wrong one, naming a line the reader can open and that has
159
+ * nothing to do with what failed. Restricting the search to the file that will
160
+ * be named makes the two halves of that position come from the same place.
161
+ *
162
+ * `null` when the file is on no frame, which is a failure raised from work the
163
+ * case left behind: no line of it is the one that failed, and printing none is
164
+ * the honest answer.
165
+ */
166
+ export function siteInFile(stack: string | null | void, file: string): Site | null {
167
+ if (stack == null) {
168
+ return null;
169
+ }
170
+ for (const frame of stack.split("\n").slice(1)) {
171
+ if (!frame.includes(file)) {
172
+ continue;
173
+ }
174
+ const site = frameSite(frame);
175
+ if (site != null) {
176
+ return site;
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+
90
182
  /**
91
183
  * `stack` with the runner's own frames removed.
92
184
  *