@uniflowed/test 0.9.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 +2 -2
- package/internal/asymmetric.js +5 -2
- package/internal/browser/cdp.js +10 -1
- package/internal/browser/page.js +3 -0
- package/internal/browser/screenshots.js +11 -4
- package/internal/browser/server.js +3 -2
- package/internal/callable.js +28 -0
- package/internal/equality.js +10 -1
- package/internal/expect.js +8 -7
- package/internal/modules.js +12 -3
- package/internal/spy.js +4 -1
- package/package.json +2 -2
- package/worker.js +3 -0
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) => {
|
package/internal/asymmetric.js
CHANGED
|
@@ -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
|
-
|
|
95
|
+
default: {
|
|
96
|
+
const type = callable(constructor);
|
|
97
|
+
return type != null && received instanceof type;
|
|
98
|
+
}
|
|
96
99
|
}
|
|
97
100
|
});
|
|
98
101
|
}
|
package/internal/browser/cdp.js
CHANGED
|
@@ -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;
|
package/internal/browser/page.js
CHANGED
|
@@ -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
|
|
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 ??
|
|
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 ??
|
|
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:
|
|
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
|
+
}
|
package/internal/equality.js
CHANGED
|
@@ -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
|
|
52
|
+
return String(objectToString?.call(value));
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
/**
|
package/internal/expect.js
CHANGED
|
@@ -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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
+
run();
|
|
541
542
|
} catch (error) {
|
|
542
543
|
threw = true;
|
|
543
544
|
thrown = error;
|
package/internal/modules.js
CHANGED
|
@@ -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:
|
|
423
|
-
const prototype = Object.getPrototypeOf(value);
|
|
424
|
-
return prototype === null || prototype ===
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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) {
|