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

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
  }
@@ -137,7 +89,9 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
137
89
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
138
90
  * `.not` reuse all of them.
139
91
  */
140
- function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>) => Verdict } {
92
+ function verdicts(received: mixed): {
93
+ readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
94
+ } {
141
95
  const shown = () => render(received);
142
96
  const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
143
97
  pass,
@@ -146,7 +100,8 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
146
100
  failure: () => `expected ${shown()} ${what}`,
147
101
  negatedFailure: () => `expected ${shown()} not ${what}`,
148
102
  });
149
- const spyCalls = (): Array<SpyCall> => (isSpy(received) ? (received: $FlowFixMe).mock.calls : []);
103
+ const spyCalls = (): Array<SpyCall> =>
104
+ isSpy(received) ? (received as $FlowFixMe).mock.calls : [];
150
105
  const requireSpy = (matcher: string) => {
151
106
  if (!isSpy(received)) {
152
107
  throw new AssertionError(
@@ -187,17 +142,33 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
187
142
  toBeDefined: () => simple(received !== undefined, "to be defined"),
188
143
  toBeNaN: () => simple(typeof received === "number" && Number.isNaN(received), "to be NaN"),
189
144
  toBeGreaterThan: (expected: mixed) =>
190
- simple((received: $FlowFixMe) > (expected: $FlowFixMe), `to be greater than ${render(expected)}`, expected),
145
+ simple(
146
+ (received as $FlowFixMe) > (expected as $FlowFixMe),
147
+ `to be greater than ${render(expected)}`,
148
+ expected,
149
+ ),
191
150
  toBeGreaterThanOrEqual: (expected: mixed) =>
192
- simple((received: $FlowFixMe) >= (expected: $FlowFixMe), `to be at least ${render(expected)}`, expected),
151
+ simple(
152
+ (received as $FlowFixMe) >= (expected as $FlowFixMe),
153
+ `to be at least ${render(expected)}`,
154
+ expected,
155
+ ),
193
156
  toBeLessThan: (expected: mixed) =>
194
- simple((received: $FlowFixMe) < (expected: $FlowFixMe), `to be less than ${render(expected)}`, expected),
157
+ simple(
158
+ (received as $FlowFixMe) < (expected as $FlowFixMe),
159
+ `to be less than ${render(expected)}`,
160
+ expected,
161
+ ),
195
162
  toBeLessThanOrEqual: (expected: mixed) =>
196
- simple((received: $FlowFixMe) <= (expected: $FlowFixMe), `to be at most ${render(expected)}`, expected),
163
+ simple(
164
+ (received as $FlowFixMe) <= (expected as $FlowFixMe),
165
+ `to be at most ${render(expected)}`,
166
+ expected,
167
+ ),
197
168
  toBeCloseTo: (expected: number, digits?: number) => {
198
169
  const places = digits ?? 2;
199
170
  const tolerance = 10 ** -places / 2;
200
- const difference = Math.abs((received: $FlowFixMe) - expected);
171
+ const difference = Math.abs((received as $FlowFixMe) - expected);
201
172
  return simple(
202
173
  difference < tolerance,
203
174
  `to be within ${tolerance} of ${expected}, but it is off by ${difference}`,
@@ -216,7 +187,11 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
216
187
  return simple(pass, `to contain ${render(expected)}`, expected);
217
188
  },
218
189
  toContainEqual: (expected: mixed) => {
219
- const items = Array.isArray(received) ? received : received instanceof Set ? [...received] : [];
190
+ const items = Array.isArray(received)
191
+ ? received
192
+ : received instanceof Set
193
+ ? [...received]
194
+ : [];
220
195
  return simple(
221
196
  items.some((item) => equals(item, expected)),
222
197
  `to contain something equal to ${render(expected)}`,
@@ -224,8 +199,12 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
224
199
  );
225
200
  },
226
201
  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);
202
+ const length = received == null ? undefined : (received as $FlowFixMe).length;
203
+ return simple(
204
+ length === expected,
205
+ `to have length ${expected}, not ${render(length)}`,
206
+ expected,
207
+ );
229
208
  },
230
209
  toHaveProperty: (path: string, ...rest: $ReadOnlyArray<mixed>) => {
231
210
  const found = propertyAt(received, path);
@@ -240,21 +219,60 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
240
219
  },
241
220
  toMatch: (expected: mixed) => {
242
221
  const text = typeof received === "string" ? received : String(received);
243
- const pass = typeof expected === "string" ? text.includes(expected) : (expected: $FlowFixMe).test(text);
222
+ const pass =
223
+ typeof expected === "string"
224
+ ? text.includes(expected)
225
+ : (expected as $FlowFixMe).test(text);
244
226
  return simple(pass, `to match ${render(expected)}`, expected);
245
227
  },
246
228
  toMatchObject: (expected: mixed) =>
247
229
  simple(matchesObject(received, expected), `to match ${render(expected)}`, expected),
248
230
  toBeInstanceOf: (expected: mixed) =>
249
231
  simple(
250
- typeof expected === "function" && received instanceof (expected: $FlowFixMe),
232
+ typeof expected === "function" && received instanceof (expected as $FlowFixMe),
251
233
  `to be an instance of ${render(expected)}`,
252
234
  expected,
253
235
  ),
254
236
  toBeTypeOf: (expected: string) =>
255
- simple(typeof received === expected, `to be of type ${expected}, not ${typeof received}`, expected),
237
+ simple(
238
+ typeof received === expected,
239
+ `to be of type ${expected}, not ${typeof received}`,
240
+ expected,
241
+ ),
256
242
  toSatisfy: (predicate: (value: mixed) => boolean) =>
257
243
  simple(predicate(received) === true, "to satisfy the predicate"),
244
+ toMatchSnapshot: (hint?: string): Verdict => {
245
+ const verdict = snapshot.matchSnapshot(received, hint);
246
+ return {
247
+ pass: verdict.pass,
248
+ expected: verdict.expected ?? "(no snapshot yet)",
249
+ received: verdict.received,
250
+ // The whole of both sides, because a mismatch that says only "the
251
+ // snapshot did not match" makes a reader open two files.
252
+ failure: () =>
253
+ `snapshot did not match.\n\nstored:\n${verdict.expected ?? "(none)"}\n\n` +
254
+ `received:\n${verdict.received}\n\n` +
255
+ "Run `uf test -u` if the new value is the right one.",
256
+ negatedFailure: () => "expected the value not to match its snapshot",
257
+ };
258
+ },
259
+ toMatchInlineSnapshot: (expected?: string): Verdict => {
260
+ const verdict = snapshot.matchInlineSnapshot(received, expected);
261
+ return {
262
+ pass: verdict.pass,
263
+ expected: verdict.expected ?? "(no inline snapshot yet)",
264
+ received: verdict.received,
265
+ failure: () =>
266
+ verdict.expected == null
267
+ ? // uf does not rewrite a test file — a tool that edits the file you
268
+ // are editing is a tool that loses work — so it reports what to
269
+ // paste in and leaves the decision to a person.
270
+ `no inline snapshot yet. Paste this into the call:\n\n\`\`\`\n${verdict.received}\n\`\`\``
271
+ : `inline snapshot did not match.\n\nstored:\n${verdict.expected}\n\n` +
272
+ `received:\n${verdict.received}`,
273
+ negatedFailure: () => "expected the value not to match its inline snapshot",
274
+ };
275
+ },
258
276
  toThrow: (...rest: $ReadOnlyArray<mixed>) => {
259
277
  const expected = rest[0];
260
278
  if (typeof received !== "function") {
@@ -314,7 +332,157 @@ function verdicts(received: mixed): { +[string]: (...args: $ReadOnlyArray<any>)
314
332
  args,
315
333
  );
316
334
  },
335
+
336
+ // ---------------------------------------------------------------- //
337
+ // Elements
338
+ //
339
+ // These read properties of whatever they are given, so this module still
340
+ // needs no DOM and no dependency on one: an element is an object with a
341
+ // `tagName`, and a process without a document simply never has one to
342
+ // pass in. `element` says so when it does not.
343
+ // ---------------------------------------------------------------- //
344
+
345
+ toBeInTheDocument: () => {
346
+ const node = element("toBeInTheDocument");
347
+ const root = node.ownerDocument;
348
+ return simple(root != null && root.contains(node), "to be in the document");
349
+ },
350
+ toBeVisible: () => {
351
+ const node = element("toBeVisible");
352
+ return simple(isVisible(node), "to be visible");
353
+ },
354
+ toBeDisabled: () => {
355
+ const node = element("toBeDisabled");
356
+ return simple(isDisabled(node), "to be disabled");
357
+ },
358
+ toBeEnabled: () => {
359
+ const node = element("toBeEnabled");
360
+ return simple(!isDisabled(node), "to be enabled");
361
+ },
362
+ toBeChecked: () => {
363
+ const node = element("toBeChecked");
364
+ const aria = node.getAttribute("aria-checked");
365
+ const checked = aria != null ? aria === "true" : (node as $FlowFixMe).checked === true;
366
+ return simple(checked, "to be checked");
367
+ },
368
+ toBeRequired: () => {
369
+ const node = element("toBeRequired");
370
+ return simple(
371
+ (node as $FlowFixMe).required === true || node.getAttribute("aria-required") === "true",
372
+ "to be required",
373
+ );
374
+ },
375
+ toHaveFocus: () => {
376
+ const node = element("toHaveFocus");
377
+ return simple(node.ownerDocument?.activeElement === node, "to have focus");
378
+ },
379
+ toHaveAttribute: (name: mixed, value?: mixed) => {
380
+ const node = element("toHaveAttribute");
381
+ const actual = node.getAttribute(String(name));
382
+ if (value === undefined) {
383
+ return simple(actual != null, `to have the attribute ${render(name)}`, name);
384
+ }
385
+ return {
386
+ pass: actual === String(value),
387
+ expected: render(value),
388
+ received: render(actual),
389
+ failure: () => `expected ${render(name)} to be ${render(value)}, not ${render(actual)}`,
390
+ };
391
+ },
392
+ toHaveClass: (...names: $ReadOnlyArray<mixed>) => {
393
+ const node = element("toHaveClass");
394
+ const classes = (node.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
395
+ const wanted = names.map(String);
396
+ return {
397
+ pass: wanted.every((name) => classes.includes(name)),
398
+ expected: render(wanted),
399
+ received: render(classes),
400
+ failure: () => `expected the class list ${render(classes)} to include ${render(wanted)}`,
401
+ };
402
+ },
403
+ toHaveTextContent: (expected: mixed) => {
404
+ const node = element("toHaveTextContent");
405
+ const text = (node.textContent ?? "").replace(/\s+/g, " ").trim();
406
+ const pass =
407
+ expected instanceof RegExp ? expected.test(text) : text.includes(String(expected));
408
+ return {
409
+ pass,
410
+ expected: render(expected),
411
+ received: render(text),
412
+ failure: () => `expected the text ${render(text)} to contain ${render(expected)}`,
413
+ };
414
+ },
415
+ toHaveValue: (expected: mixed) => {
416
+ const node = element("toHaveValue");
417
+ const actual = (node as $FlowFixMe).value;
418
+ return {
419
+ pass: equals(actual, expected),
420
+ expected: render(expected),
421
+ received: render(actual),
422
+ failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
423
+ };
424
+ },
317
425
  };
426
+
427
+ /**
428
+ * The received value as an element, or a failure that says what it was.
429
+ *
430
+ * An element matcher applied to a string is almost always a query whose
431
+ * result was used without being awaited, and "received a Promise" is a much
432
+ * better message than a `TypeError` about `getAttribute`.
433
+ */
434
+ function element(matcher: string): Element {
435
+ const node: $FlowFixMe = received;
436
+ if (node == null || typeof node.getAttribute !== "function") {
437
+ throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
438
+ }
439
+ return node;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Whether a reader would see this element.
445
+ *
446
+ * Walks the ancestors, because `display: none` on a parent hides a child whose
447
+ * own style says nothing. `hidden`, `aria-hidden` and a `details` that is not
448
+ * open each hide their subtree too.
449
+ */
450
+ function isVisible(node: Element): boolean {
451
+ let current: $FlowFixMe = node;
452
+ while (current != null && current.nodeType === 1) {
453
+ if (current.hasAttribute("hidden") || current.getAttribute("aria-hidden") === "true") {
454
+ return false;
455
+ }
456
+ if (current.tagName === "DETAILS" && !current.hasAttribute("open") && current !== node) {
457
+ return false;
458
+ }
459
+ const style = current.ownerDocument?.defaultView?.getComputedStyle?.(current);
460
+ if (style != null) {
461
+ if (style.display === "none" || style.visibility === "hidden") {
462
+ return false;
463
+ }
464
+ if (style.opacity === "0") {
465
+ return false;
466
+ }
467
+ }
468
+ current = current.parentElement;
469
+ }
470
+ return true;
471
+ }
472
+
473
+ /** Whether the control is disabled, by its own attribute or a fieldset's. */
474
+ function isDisabled(node: Element): boolean {
475
+ let current: $FlowFixMe = node;
476
+ while (current != null && current.nodeType === 1) {
477
+ if (current.hasAttribute("disabled")) {
478
+ return true;
479
+ }
480
+ if (current.getAttribute("aria-disabled") === "true") {
481
+ return true;
482
+ }
483
+ current = current.parentElement;
484
+ }
485
+ return false;
318
486
  }
319
487
 
320
488
  /**
@@ -333,7 +501,12 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
333
501
  return undefined;
334
502
  }
335
503
  const message = negated ? verdict.negatedFailure() : verdict.failure();
336
- throw new AssertionError(message, name, verdict.expected ?? "", verdict.received ?? render(received));
504
+ throw new AssertionError(
505
+ message,
506
+ name,
507
+ verdict.expected ?? "",
508
+ verdict.received ?? render(received),
509
+ );
337
510
  };
338
511
  }
339
512
  Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
@@ -351,7 +524,7 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
351
524
  let value: mixed;
352
525
  let rejected = false;
353
526
  try {
354
- value = await (promise: $FlowFixMe);
527
+ value = await (promise as $FlowFixMe);
355
528
  } catch (error) {
356
529
  rejected = true;
357
530
  value = error;
@@ -399,9 +572,49 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
399
572
  * await expect(load()).resolves.toHaveLength(3);
400
573
  * ```
401
574
  */
402
- export function expect(received: mixed): $FlowFixMe {
575
+ function expectValue(received: mixed): $FlowFixMe {
403
576
  const expectation: $FlowFixMe = bind(received, false);
404
- Object.defineProperty(expectation, "resolves", { get: () => settled(received, "resolve", false) });
577
+ Object.defineProperty(expectation, "resolves", {
578
+ get: () => settled(received, "resolve", false),
579
+ });
405
580
  Object.defineProperty(expectation, "rejects", { get: () => settled(received, "reject", false) });
406
581
  return expectation;
407
582
  }
583
+
584
+ /**
585
+ * Assert about a value.
586
+ *
587
+ * Declared with its matchers attached rather than assigned afterwards: a
588
+ * shipped module may only declare, import and export at its top level, and
589
+ * `expect.any = …` is a statement that runs when the module is imported.
590
+ *
591
+ * The `expect.*` half are the matchers that stand in for a value instead of
592
+ * being one. `expect(user).toEqual({ id: expect.any(String), name: "uf" })`
593
+ * says what a test means; spelling out the id would either be a lie or a second
594
+ * source of truth. They work at any depth, because `equals` asks every value it
595
+ * meets whether it is one.
596
+ *
597
+ * `expect.not.*` is the negated form, spelled the way Jest and Vitest spell it
598
+ * — `expect.not.objectContaining({ error: expect.anything() })` reads better
599
+ * than a negated assertion around the whole object, and is the form a suite
600
+ * being ported will already have.
601
+ */
602
+ export const expect: $FlowFixMe = Object.assign(expectValue, {
603
+ any: asymmetric.any,
604
+ anything: asymmetric.anything,
605
+ objectContaining: asymmetric.objectContaining,
606
+ arrayContaining: asymmetric.arrayContaining,
607
+ stringContaining: asymmetric.stringContaining,
608
+ stringMatching: asymmetric.stringMatching,
609
+ closeTo: asymmetric.closeTo,
610
+ not: {
611
+ objectContaining: (expected: interface {}) =>
612
+ asymmetric.not(asymmetric.objectContaining(expected)),
613
+ arrayContaining: (expected: $ReadOnlyArray<mixed>) =>
614
+ asymmetric.not(asymmetric.arrayContaining(expected)),
615
+ stringContaining: (substring: string) => asymmetric.not(asymmetric.stringContaining(substring)),
616
+ stringMatching: (pattern: string | RegExp) =>
617
+ asymmetric.not(asymmetric.stringMatching(pattern)),
618
+ closeTo: (value: number, digits?: number) => asymmetric.not(asymmetric.closeTo(value, digits)),
619
+ },
620
+ });
@@ -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
  }