@uniflowed/test 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/browser-worker.js CHANGED
@@ -48,7 +48,7 @@ import path from "node:path";
48
48
  import { inSourceTests, sharedService } from "@uniflowed/host/transform";
49
49
 
50
50
  import { create } from "./internal/browser/server.js";
51
- import { connectPipe } from "./internal/browser/cdp.js";
51
+ import { type PipeController, connectPipe } from "./internal/browser/cdp.js";
52
52
 
53
53
  /** What `uf` sends for one file. */
54
54
  type Request = {|
@@ -162,7 +162,7 @@ async function main(): Promise<void> {
162
162
  const browser = process.env.UF_BROWSER;
163
163
  const service = sharedService(root);
164
164
 
165
- let controller = null;
165
+ let controller: PipeController | null = null;
166
166
  const server = await create({
167
167
  root,
168
168
  browserCommand: async (id, method, args) => {
@@ -170,7 +170,7 @@ async function main(): Promise<void> {
170
170
  return controller.command(id, method, args);
171
171
  },
172
172
  transform: async (id, code) => {
173
- // The same three the Node loader passes (`packages/host/internal/
173
+ // The same three the Node loader passes (`npm/host/internal/
174
174
  // node-hooks.js`), so a module means the same thing on both hosts.
175
175
  // `inSourceTests` is the one that would be easy to forget and expensive
176
176
  // to: without it `import.meta.uf.test` compiles to `void 0`, every
package/index.js CHANGED
@@ -52,8 +52,8 @@ export { fn, spyOn } from "./internal/spy.js";
52
52
  * would be claiming something uf has not earned.
53
53
  *
54
54
  * A namespace rather than loose named exports, because several of these names
55
- * are generic enough to collide: `@uniflowed/testing` re-exports both this
56
- * package and `@uniflowed/react-testing`, and both have a `waitFor`.
55
+ * are generic enough to collide with `@uniflowed/react-testing`, which
56
+ * also exports a `waitFor`.
57
57
  */
58
58
  export { UnsupportedError, uft } from "./internal/namespace.js";
59
59
 
@@ -21,6 +21,7 @@
21
21
  // the time any matcher runs, both modules have finished evaluating — and the
22
22
  // alternative was wiring the comparison in at load, which is an import-time side
23
23
  // effect and the one thing every shipped module here is forbidden.
24
+ import { callable } from "./callable.js";
24
25
  import { equals } from "./equality.js";
25
26
 
26
27
  /** The brand every matcher carries, so `equals` can recognise one. */
@@ -91,8 +92,10 @@ export function any(constructor: mixed): AsymmetricMatcher {
91
92
  // one means — not "has Object.prototype in its chain", which a
92
93
  // null-prototype object would fail and a test would find baffling.
93
94
  return received != null && (typeof received === "object" || typeof received === "function");
94
- default:
95
- return typeof constructor === "function" && received instanceof constructor;
95
+ default: {
96
+ const type = callable(constructor);
97
+ return type != null && received instanceof type;
98
+ }
96
99
  }
97
100
  });
98
101
  }
@@ -24,6 +24,15 @@ export type BrowserTransport = {|
24
24
  ) => Promise<WireValue>,
25
25
  readonly close: () => Promise<void>,
26
26
  |};
27
+ /** The page commands `connectPipe` answers over one browser connection. */
28
+ export type PipeController = {|
29
+ readonly command: (
30
+ id: string | null,
31
+ method: string,
32
+ args?: $ReadOnlyArray<WireValue>,
33
+ ) => Promise<WireValue>,
34
+ readonly closePages: () => Promise<void>,
35
+ |};
27
36
  type Pending = {|
28
37
  timer: TimeoutID,
29
38
  resolve: (value: WireValue) => void,
@@ -31,7 +40,7 @@ type Pending = {|
31
40
  |};
32
41
 
33
42
  /** CDP over the private pipe: closing the test worker also closes Chromium. */
34
- export function connectPipe(child: $FlowFixMe, options: ControlOptions = {}) {
43
+ export function connectPipe(child: $FlowFixMe, options: ControlOptions = {}): PipeController {
35
44
  let sequence = 0;
36
45
  let buffered = "";
37
46
  let closed = false;
@@ -123,6 +123,9 @@ async function runFile(request: PageRequest): Promise<void> {
123
123
  const uninstall = installInSourceTests(url);
124
124
  try {
125
125
  try {
126
+ // The test file the runner found, served by the dev server; Flow types
127
+ // only a literal specifier, and nothing is read from its namespace.
128
+ // $FlowFixMe[unsupported-syntax]
126
129
  await import(url);
127
130
  } catch (thrown) {
128
131
  const error = asError(thrown);
@@ -10,12 +10,19 @@ export type ScreenshotOptions = {|
10
10
  readonly threshold?: number,
11
11
  |};
12
12
 
13
+ /** A screenshot that matched its baseline, or was recorded as the new one. */
14
+ export type ScreenshotResult = {|
15
+ readonly baseline: string,
16
+ readonly updated: boolean,
17
+ readonly differentPixels: number,
18
+ |};
19
+
13
20
  /** Baselines stay under the project's configured directory, including through symlinks. */
14
21
  export async function compareScreenshot(
15
22
  data: string,
16
23
  name: string,
17
24
  options: ScreenshotOptions = {},
18
- ) {
25
+ ): Promise<ScreenshotResult> {
19
26
  if (
20
27
  typeof name !== "string" ||
21
28
  !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(name) ||
@@ -23,9 +30,9 @@ export async function compareScreenshot(
23
30
  /\.(actual|diff)$/.test(name)
24
31
  )
25
32
  throw new Error("screenshot name must be a simple file name without traversal");
26
- const config = JSON.parse(process.env.UF_VRT_CONFIG ?? "{}");
33
+ const vrt = JSON.parse(process.env.UF_VRT_CONFIG ?? "{}");
27
34
  const root = await realpath(options.root ?? process.env.UF_PROJECT_ROOT ?? process.cwd());
28
- const directory = path.resolve(root, options.baselines ?? config.baselines ?? "__uf_vrt__");
35
+ const directory = path.resolve(root, options.baselines ?? vrt.baselines ?? "__uf_vrt__");
29
36
  if (!directory.startsWith(root + path.sep))
30
37
  throw new Error("vrt.baselines must stay inside the test project");
31
38
  // Check each existing ancestor before creating anything through it.
@@ -49,7 +56,7 @@ export async function compareScreenshot(
49
56
  }
50
57
  const bytes = Buffer.from(data, "base64");
51
58
  const actual = PNG.sync.read(bytes);
52
- const threshold = options.threshold ?? config.threshold ?? 0;
59
+ const threshold = options.threshold ?? vrt.threshold ?? 0;
53
60
  if (!Number.isInteger(threshold) || threshold < 0)
54
61
  throw new Error("vrt.threshold is the maximum number of different pixels");
55
62
  if (process.env.UF_UPDATE_SNAPSHOTS === "1") {
@@ -124,7 +124,7 @@ export type PageRequest = {|
124
124
  /** How `create` is configured. */
125
125
  export type ServerOptions = {|
126
126
  readonly browserCommand?: (
127
- id: mixed,
127
+ id: string | null,
128
128
  method: string,
129
129
  args: $ReadOnlyArray<mixed>,
130
130
  ) => Promise<mixed>,
@@ -226,6 +226,7 @@ export async function create(options: ServerOptions): Promise<ModuleServer> {
226
226
  try {
227
227
  const { id, method, args } = JSON.parse(body);
228
228
  if (
229
+ (id !== null && typeof id !== "string") ||
229
230
  typeof method !== "string" ||
230
231
  !Array.isArray(args) ||
231
232
  options.browserCommand == null
@@ -279,7 +280,7 @@ export async function create(options: ServerOptions): Promise<ModuleServer> {
279
280
  server.listen(0, "127.0.0.1", resolve);
280
281
  });
281
282
  const address = server.address();
282
- const port = address != null && typeof address === "object" ? address.port : 0;
283
+ const port: number = address != null && typeof address === "object" ? address.port : 0;
283
284
 
284
285
  return {
285
286
  url: `http://127.0.0.1:${String(port)}${PAGE_PATH}`,
@@ -0,0 +1,28 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: a value that is a function, as one to call.
4
+ //
5
+ // A test hands this package values whose types it cannot know: the subject of
6
+ // `expect(() => ...).toThrow()`, the implementation given to `fn(body)`, the
7
+ // constructor in `expect.any(Date)`. Each is checked with `typeof` before it is
8
+ // used, and that check is all JavaScript offers. Flow reads `typeof value ===
9
+ // "function"` as a function whose parameters are unknown, which it will not
10
+ // call, construct with or put on the right of `instanceof`.
11
+ //
12
+ // So this is the one place that turns such a value into something callable:
13
+ // the run-time check and the single suppression that stands for it live
14
+ // together, rather than an `any` at every call site.
15
+
16
+ /** A function of unknown signature: it accepts any `this` and arguments. */
17
+ export type Callable = (this: mixed, ...args: $ReadOnlyArray<mixed>) => mixed;
18
+
19
+ /** `value` as a function to call, or `null` when it is not a function. */
20
+ export function callable(value: mixed): Callable | null {
21
+ if (typeof value !== "function") {
22
+ return null;
23
+ }
24
+ // Checked just above, which is as far as JavaScript can check a function;
25
+ // Flow has no type for "a function with unknown parameters" that it calls.
26
+ // $FlowFixMe[incompatible-type]
27
+ return value;
28
+ }
@@ -19,6 +19,7 @@
19
19
  // which is the one place the two matchers differ besides prototypes.
20
20
 
21
21
  import { isAsymmetric, matchesAsymmetric } from "./asymmetric.js";
22
+ import { callable } from "./callable.js";
22
23
 
23
24
  /** How strictly two values are compared. */
24
25
  export type Strictness = "loose" | "strict";
@@ -39,8 +40,16 @@ function isObject(value: mixed): value is interface {} {
39
40
  return typeof value === "object" && value !== null;
40
41
  }
41
42
 
43
+ /**
44
+ * `Object.prototype.toString`, read through its descriptor: Flow refuses the
45
+ * method as a value, and `call` needs it as one to read any value's tag.
46
+ */
47
+ const objectToString = callable(
48
+ Reflect.getOwnPropertyDescriptor(Object.prototype, "toString")?.value,
49
+ );
50
+
42
51
  function tag(value: mixed): string {
43
- return Object.prototype.toString.call(value);
52
+ return String(objectToString?.call(value));
44
53
  }
45
54
 
46
55
  /**
@@ -71,6 +71,7 @@ import type { SpyCall } from "./spy.js";
71
71
  import * as asymmetric from "./asymmetric.js";
72
72
  import * as snapshot from "./snapshot.js";
73
73
  import { auditElement, describeViolations, violationIds } from "./axe.js";
74
+ import { callable } from "./callable.js";
74
75
  import { isSpy } from "./spy.js";
75
76
  import { equals, matchesObject, render } from "./equality.js";
76
77
 
@@ -486,11 +487,10 @@ function verdicts(received: mixed): {
486
487
  name,
487
488
  );
488
489
  },
489
- toSatisfy: (predicate: mixed) =>
490
- simple(
491
- typeof predicate === "function" && predicate(received) === true,
492
- "to satisfy the predicate",
493
- ),
490
+ toSatisfy: (predicate: mixed) => {
491
+ const check = callable(predicate);
492
+ return simple(check != null && check(received) === true, "to satisfy the predicate");
493
+ },
494
494
  toMatchSnapshot: (hint?: mixed): Verdict => {
495
495
  const verdict = snapshot.matchSnapshot(
496
496
  received,
@@ -531,13 +531,14 @@ function verdicts(received: mixed): {
531
531
  },
532
532
  toThrow: (...rest: $ReadOnlyArray<mixed>) => {
533
533
  const expected = rest[0];
534
- if (typeof received !== "function") {
534
+ const run = callable(received);
535
+ if (run == null) {
535
536
  return simple(false, "to be a function, so it could be called");
536
537
  }
537
538
  let thrown: mixed;
538
539
  let threw = false;
539
540
  try {
540
- received();
541
+ run();
541
542
  } catch (error) {
542
543
  threw = true;
543
544
  thrown = error;
@@ -752,7 +753,7 @@ function verdicts(received: mixed): {
752
753
  *
753
754
  * # Why this is not the walk in `react-testing`
754
755
  *
755
- * `packages/react-testing/internal/queries.js` has one that looks like this
756
+ * `npm/react-testing/internal/queries.js` has one that looks like this
756
757
  * and answers a different question. `exposed` asks whether the accessibility
757
758
  * tree announces the element, so it ignores `opacity: 0` — a screen reader
758
759
  * reads text at zero opacity, which is exactly why hiding text that way is a
@@ -29,9 +29,9 @@ export type Site = {| readonly line: number, readonly column: number |};
29
29
  * report is concerned.
30
30
  */
31
31
  const INTERNAL_MARKERS = [
32
- "/packages/test/internal/",
33
- "/packages/test/worker.js",
34
- "/packages/react-testing/",
32
+ "/npm/test/internal/",
33
+ "/npm/test/worker.js",
34
+ "/npm/react-testing/",
35
35
  "/@uniflowed/test/",
36
36
  "/@uniflowed/react-testing/",
37
37
  "node:internal/",
@@ -290,7 +290,7 @@ function sourceMapsForCallSites(): FindSourceMap | false {
290
290
  * with the lookup Node's printer itself uses — `findSourceMap(file)` then
291
291
  * `findEntry(line - 1, column - 1)`, falling back to the generated position
292
292
  * when there is no map or no entry — so the number is the one the string would
293
- * have carried. `packages/test/registration-site.test.js` holds the two paths
293
+ * have carried. `npm/test/registration-site.test.js` holds the two paths
294
294
  * to that.
295
295
  */
296
296
  export function callerSite(skip: (...args: $ReadOnlyArray<empty>) => mixed): Site | null | void {
@@ -287,6 +287,9 @@ function isPathSpecifier(specifier: string): boolean {
287
287
 
288
288
  /** `import()`, in one place, so the marker parameter is never spelled twice. */
289
289
  function importURL<Module>(url: string): Promise<Module> {
290
+ // The URL is a module the test asked to mock or load, known only at run
291
+ // time; Flow types only a literal specifier.
292
+ // $FlowFixMe[unsupported-syntax]
290
293
  return import(url) as $FlowFixMe;
291
294
  }
292
295
 
@@ -419,11 +422,17 @@ function copyProperties(
419
422
  * not — replacing their methods with spies would produce something that claims
420
423
  * to be a `Date` and cannot tell the time.
421
424
  */
422
- function isPlainish(value: mixed): boolean {
423
- const prototype = Object.getPrototypeOf(value);
424
- return prototype === null || prototype === Object.prototype || isNamespace(value);
425
+ function isPlainish(value: interface {}): boolean {
426
+ const prototype: mixed = Object.getPrototypeOf(value);
427
+ return prototype === null || prototype === OBJECT_PROTOTYPE || isNamespace(value);
425
428
  }
426
429
 
430
+ /**
431
+ * `Object.prototype`, read off a literal: Flow's library declares no static
432
+ * `prototype` on `Object`, and a literal's prototype is exactly that object.
433
+ */
434
+ const OBJECT_PROTOTYPE: mixed = Object.getPrototypeOf({});
435
+
427
436
  /** Whether `value` is a module namespace object. */
428
437
  function isNamespace(value: mixed): boolean {
429
438
  return (value as $FlowFixMe)[Symbol.toStringTag] === "Module";
@@ -8,8 +8,8 @@
8
8
  // claiming something uf has not earned.
9
9
  //
10
10
  // A namespace rather than loose named exports, because several of these names
11
- // are generic enough to collide in a test file: `@uniflowed/testing` re-exports
12
- // both this package and `@uniflowed/react-testing`, and both have a `waitFor`.
11
+ // are generic enough to collide in a test file: this package and
12
+ // `@uniflowed/react-testing` both have a `waitFor`.
13
13
  //
14
14
  // `uft` rather than `uf`, and rather than `uf.test`. A bare `uf` is the command
15
15
  // and the project, and a test file would be using the name for something much
package/internal/spy.js CHANGED
@@ -36,6 +36,8 @@
36
36
  // Every spy is registered, so `uft.clearAllMocks` and its siblings can reach the
37
37
  // ones a test never held a reference to.
38
38
 
39
+ import { callable } from "./callable.js";
40
+
39
41
  /** One call's arguments, as Vitest's `mock.calls` holds them. */
40
42
  export type SpyCall = $ReadOnlyArray<mixed>;
41
43
 
@@ -118,7 +120,8 @@ function makeSpy(implementation: mixed, restore: Restore, name: string): $FlowFi
118
120
  ? Reflect.construct(body as $FlowFixMe, [...args], new.target)
119
121
  : this;
120
122
  } else {
121
- returned = typeof body === "function" ? body.apply(this, args) : undefined;
123
+ const run = callable(body);
124
+ returned = run != null ? run.apply(this, args) : undefined;
122
125
  }
123
126
  } catch (thrown) {
124
127
  result.type = "throw";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
- "directory": "packages/test"
11
+ "directory": "npm/test"
12
12
  },
13
13
  "exports": {
14
14
  ".": {
@@ -38,7 +38,7 @@
38
38
  "!*.test.js"
39
39
  ],
40
40
  "dependencies": {
41
- "@uniflowed/host": "0.9.0",
41
+ "@uniflowed/host": "0.11.0",
42
42
  "pixelmatch": "^7.1.0",
43
43
  "pngjs": "^7.0.0"
44
44
  },
package/worker.js CHANGED
@@ -242,6 +242,9 @@ async function runImportedFile(
242
242
  const { loadNativeFile } = await import("./internal/native-host.js");
243
243
  closeNative = await loadNativeFile(request.file);
244
244
  } else {
245
+ // The test file the runner found; Flow types only a literal specifier,
246
+ // and nothing is read from its namespace.
247
+ // $FlowFixMe[unsupported-syntax]
245
248
  await import(`${url}?uf-run=${generation}`);
246
249
  }
247
250
  } catch (thrown) {