@uniflowed/test 0.8.0 → 0.10.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) => {
@@ -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;
@@ -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";
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.8.0",
3
+ "version": "0.10.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",
@@ -38,7 +38,7 @@
38
38
  "!*.test.js"
39
39
  ],
40
40
  "dependencies": {
41
- "@uniflowed/host": "0.8.0",
41
+ "@uniflowed/host": "0.10.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) {