@lexical/fast-check 0.0.0-bootstrap.0 → 0.51.1-nightly.20260918.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/README.md +120 -2
- package/dist/LexicalFastCheck.dev.js +233 -0
- package/dist/LexicalFastCheck.js +12 -0
- package/dist/LexicalFastCheck.js.flow +15 -0
- package/dist/LexicalFastCheck.prod.js +9 -0
- package/dist/index.d.ts +33 -0
- package/dist/typescript-too-old.d.ts +18 -0
- package/package.json +64 -6
- package/src/index.ts +236 -0
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@lexical/fast-check`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://lexical.dev/docs/api/modules/lexical_fast-check)
|
|
4
|
+
|
|
5
|
+
This package derives [fast-check](https://fast-check.dev) arbitraries from the
|
|
6
|
+
serialization schemas Lexical nodes declare on `$config`, so property-based tests
|
|
7
|
+
generate exactly the serialized JSON a node's parser accepts.
|
|
8
|
+
|
|
9
|
+
Install it alongside `fast-check`, which is a peer dependency:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --save-dev @lexical/fast-check fast-check
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
[`nodeArbitrary(NodeClass)`](https://lexical.dev/docs/api/modules/lexical_fast-check#nodearbitrary) generates the node-specific properties of a
|
|
18
|
+
serialized node, composing the schemas the class inherits (an element-based
|
|
19
|
+
node also gets `direction`/`format`/`indent`/… from `ElementNode`). Every
|
|
20
|
+
property is generated independently as present or absent, because that is the
|
|
21
|
+
domain parsers actually face: an older document predates a property, and a
|
|
22
|
+
compact export omits one whose value equals its default.
|
|
23
|
+
|
|
24
|
+
The values are typed as the parse shape of the class's own serialized type —
|
|
25
|
+
`LexicalParseJSON<SerializedMyNode>` for a `MyNode` that declares
|
|
26
|
+
`exportJSON(): SerializedMyNode` — which is what its `updateFromJSON` accepts,
|
|
27
|
+
so they can be handed to it without a cast.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {nodeArbitrary} from '@lexical/fast-check';
|
|
31
|
+
import * as fc from 'fast-check';
|
|
32
|
+
|
|
33
|
+
fc.assert(
|
|
34
|
+
fc.property(nodeArbitrary(MyNode), props => {
|
|
35
|
+
const json = {...props, type: 'my-node'};
|
|
36
|
+
// importing and re-exporting is a fixed point
|
|
37
|
+
expect(exportOf(importOf(json))).toEqual(exportOf(importOf(exportOf(importOf(json)))));
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
[`nodeArbitrary`](https://lexical.dev/docs/api/modules/lexical_fast-check#nodearbitrary) reads the class's `$config`, so no editor or node instance is
|
|
43
|
+
required — but resolving it injects the class's synthesized statics and compiles
|
|
44
|
+
its accessor tables, so a class whose schema names an accessor it does not have
|
|
45
|
+
throws here. To inspect what a node declares without generating anything, call
|
|
46
|
+
`getComposedSchemaFields(NodeClass)` from `lexical` directly.
|
|
47
|
+
|
|
48
|
+
## Example: proving a clone carries your node's properties
|
|
49
|
+
|
|
50
|
+
A custom node has to copy its own properties in `afterCloneFrom`, which Lexical
|
|
51
|
+
calls whenever `getWritable()` clones the node. Declaring a new property and
|
|
52
|
+
forgetting to copy it there is a silent bug: the field still exists on the clone
|
|
53
|
+
because the constructor set it, so nothing throws — the node just reverts that
|
|
54
|
+
one property to its default the next time anything writes to it.
|
|
55
|
+
|
|
56
|
+
This is a good fit for a generated test, because a hand-written fixture tends to
|
|
57
|
+
use default values, and a dropped property compares equal to its default. The
|
|
58
|
+
test looks like it passed precisely when the bug is invisible. [`nodeArbitrary`](https://lexical.dev/docs/api/modules/lexical_fast-check#nodearbitrary)
|
|
59
|
+
draws from the schema's own domain instead, so the values are in-domain and
|
|
60
|
+
mostly not the default:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import {nodeArbitrary} from '@lexical/fast-check';
|
|
64
|
+
import * as fc from 'fast-check';
|
|
65
|
+
import {
|
|
66
|
+
$create,
|
|
67
|
+
$createParagraphNode,
|
|
68
|
+
$getNodeByKey,
|
|
69
|
+
$getRoot,
|
|
70
|
+
createEditor,
|
|
71
|
+
TextNode,
|
|
72
|
+
} from 'lexical';
|
|
73
|
+
|
|
74
|
+
test('a clone carries every property TextNode declares', () => {
|
|
75
|
+
fc.assert(
|
|
76
|
+
fc.property(nodeArbitrary(TextNode), props => {
|
|
77
|
+
const editor = createEditor({
|
|
78
|
+
nodes: [TextNode],
|
|
79
|
+
onError: e => {
|
|
80
|
+
throw e;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
let key, before, original;
|
|
84
|
+
|
|
85
|
+
// A node is only cloned *across* updates. Within one, getWritable()
|
|
86
|
+
// hands back the same object, so a single-update test never reaches
|
|
87
|
+
// afterCloneFrom at all.
|
|
88
|
+
editor.update(() => {
|
|
89
|
+
const node = $create(TextNode);
|
|
90
|
+
// Everything but `text` is used exactly as generated. An empty
|
|
91
|
+
// TextNode is removed by normalization between the two updates, so
|
|
92
|
+
// there would be nothing left to clone; a leaf also cannot be a child
|
|
93
|
+
// of the root, hence the paragraph.
|
|
94
|
+
node.updateFromJSON({...props, text: 'x'});
|
|
95
|
+
$getRoot().clear().append($createParagraphNode().append(node));
|
|
96
|
+
[key, original, before] = [node.getKey(), node, node.exportJSON()];
|
|
97
|
+
}, {discrete: true});
|
|
98
|
+
|
|
99
|
+
editor.update(() => {
|
|
100
|
+
const writable = $getNodeByKey(key).getWritable();
|
|
101
|
+
expect(writable).not.toBe(original); // a clone really happened
|
|
102
|
+
expect(writable.exportJSON()).toEqual(before);
|
|
103
|
+
}, {discrete: true});
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
When it fails, fast-check shrinks to the smallest value that exposes it, which
|
|
110
|
+
names the property for you. Deleting `this.__style = prevNode.__style` from
|
|
111
|
+
`TextNode.afterCloneFrom` reports:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
Counterexample: [{"style":" "}]
|
|
115
|
+
|
|
116
|
+
- "style": " ",
|
|
117
|
+
+ "style": "",
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A single space — the smallest style that is not the default `''`, and the only
|
|
121
|
+
kind of value that can catch this.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fc from 'fast-check';
|
|
10
|
+
import { getComposedSchemaFields, declaredAccepts } from 'lexical';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
14
|
+
*
|
|
15
|
+
* This source code is licensed under the MIT license found in the
|
|
16
|
+
* LICENSE file in the root directory of this source tree.
|
|
17
|
+
*
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derive a fast-check arbitrary for the node-specific properties of `klass`'s
|
|
23
|
+
* serialized JSON, composing the schemas it inherits
|
|
24
|
+
* — so an element-based node generates the properties it gets from ElementNode
|
|
25
|
+
* as well as its own. Spread the result with `type`/`version` to feed
|
|
26
|
+
* `importJSON`. Because each property's schema is the same one the node uses
|
|
27
|
+
* to parse its JSON, the generated values are exactly what the node's parser
|
|
28
|
+
* accepts — a single declaration powers both parsing and example generation.
|
|
29
|
+
*
|
|
30
|
+
* Every property is generated independently as present or absent
|
|
31
|
+
* (`requiredKeys: []`), because that is the domain the parsers actually face:
|
|
32
|
+
* a `SerializedPartial` may omit any node-specific property — older documents
|
|
33
|
+
* predate a property, and a compact export omits one whose value equals its
|
|
34
|
+
* default. Generating complete records only would leave the defaulting path
|
|
35
|
+
* (the whole point of a schema carrying a `defaultValue`) untested.
|
|
36
|
+
*
|
|
37
|
+
* The values are typed as what the class's schemas *accept*, composed across
|
|
38
|
+
* its `$config` chain — not as what they parse to. The two differ wherever a
|
|
39
|
+
* schema reads more than it writes, and this generator produces exactly that
|
|
40
|
+
* difference: an `aliasedValue`'s legacy spellings are in the generated domain
|
|
41
|
+
* (see the `aliased` case below), so a type describing the parsed output would
|
|
42
|
+
* be wrong about the values it hands back.
|
|
43
|
+
*/
|
|
44
|
+
function nodeArbitrary(klass) {
|
|
45
|
+
const fields = getComposedSchemaFields(klass);
|
|
46
|
+
const record = {};
|
|
47
|
+
for (const key of Object.keys(fields)) {
|
|
48
|
+
record[key] = schemaArbitrary(fields[key]);
|
|
49
|
+
}
|
|
50
|
+
// Both sides now read the same `$config` chain — the value through
|
|
51
|
+
// `getComposedSchemaFields`, the type through `LexicalSchemaInput` — so the
|
|
52
|
+
// cast is asserting that two walks of one declaration agree. Where they can
|
|
53
|
+
// still part company is a config that names no `extends`: the runtime walk
|
|
54
|
+
// falls back to the prototype parent, the type walk has nothing to follow
|
|
55
|
+
// and stops, and the type comes back missing the properties the value
|
|
56
|
+
// carries. Naming `extends` is what keeps them together.
|
|
57
|
+
//
|
|
58
|
+
// What the cast is *not* covering is a flat NodeState: this generates the
|
|
59
|
+
// JSON that carries one, and `LexicalSchemaInput` types those keys `unknown`
|
|
60
|
+
// for the same reason `StateConfig.parse` takes `unknown` — nothing records
|
|
61
|
+
// what a state accepted on the way in. It said `V` until recently, which the
|
|
62
|
+
// cast would have asserted away: a `string`-to-`Date` state generated strings
|
|
63
|
+
// that a caller could have read as dates.
|
|
64
|
+
return fc.record(record, {
|
|
65
|
+
requiredKeys: []
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The arbitrary for one schema: what its metadata describes, narrowed to what
|
|
71
|
+
* the schema itself admits.
|
|
72
|
+
*
|
|
73
|
+
* The metadata describes what the *combinator* accepts, and a schema whose
|
|
74
|
+
* author installed a membership predicate of its own admits less than that —
|
|
75
|
+
* a `nullable(stringValue())` that takes only `#`-prefixed strings still
|
|
76
|
+
* carries `nullable` metadata, so generating from the metadata alone produced
|
|
77
|
+
* values it declines. A round-trip property then failed on values this claimed
|
|
78
|
+
* were in domain, or passed while a sibling union member silently rewrote
|
|
79
|
+
* them.
|
|
80
|
+
*
|
|
81
|
+
* Filtered rather than generated differently, because the predicate is an
|
|
82
|
+
* opaque function: what it admits can only be discovered by asking it.
|
|
83
|
+
*/
|
|
84
|
+
function schemaArbitrary(schema) {
|
|
85
|
+
const arbitrary = metaArbitrary(schema.meta);
|
|
86
|
+
const accepts = declaredAccepts(schema);
|
|
87
|
+
if (accepts === undefined) {
|
|
88
|
+
return arbitrary;
|
|
89
|
+
}
|
|
90
|
+
const claims = value => accepts.call(schema, value);
|
|
91
|
+
// `filter` retries without a cap, so a predicate the metadata's domain
|
|
92
|
+
// cannot satisfy makes generation spin rather than fail — synchronously, so
|
|
93
|
+
// a test runner's own timeout cannot interrupt it and nothing names the
|
|
94
|
+
// property responsible. A `rawValue()` narrowed to strings is exactly that:
|
|
95
|
+
// its metadata describes no domain, so `raw` generates `undefined` alone and
|
|
96
|
+
// no draw is ever accepted. Probing first turns the hang into a diagnostic.
|
|
97
|
+
//
|
|
98
|
+
// Asked once per schema: the answer cannot change, and this recurses, so a
|
|
99
|
+
// node with five narrowed properties otherwise burned five thousand
|
|
100
|
+
// discarded draws every time its arbitrary was built.
|
|
101
|
+
let satisfiable = SATISFIABLE.get(schema);
|
|
102
|
+
if (satisfiable === undefined) {
|
|
103
|
+
satisfiable = fc.sample(arbitrary, {
|
|
104
|
+
numRuns: FILTER_PROBE_RUNS,
|
|
105
|
+
seed: 0
|
|
106
|
+
}).some(claims);
|
|
107
|
+
SATISFIABLE.set(schema, satisfiable);
|
|
108
|
+
}
|
|
109
|
+
if (!satisfiable) {
|
|
110
|
+
throw new Error(`nodeArbitrary: a schema of kind ${String(schema.meta.kind)} declares a membership predicate that rejected all ${FILTER_PROBE_RUNS} values its metadata generates, so no example can be drawn for it. Widen the predicate, or describe the narrowed domain in the schema's metadata.`);
|
|
111
|
+
}
|
|
112
|
+
return arbitrary.filter(claims);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether a schema's declared predicate can be satisfied over its metadata's
|
|
117
|
+
* domain — a property of the schema alone, so it is asked once, the same
|
|
118
|
+
* reason the core memoizes `$isCatchAll`.
|
|
119
|
+
*/
|
|
120
|
+
const SATISFIABLE = new WeakMap();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How many draws {@link schemaArbitrary} tries before calling a declared
|
|
124
|
+
* predicate unsatisfiable over its metadata's domain. Large enough that a
|
|
125
|
+
* predicate accepting a reasonable fraction is never mistaken for one that
|
|
126
|
+
* accepts nothing, small enough to cost nothing next to a property run.
|
|
127
|
+
*/
|
|
128
|
+
const FILTER_PROBE_RUNS = 1000;
|
|
129
|
+
function metaArbitrary(meta) {
|
|
130
|
+
switch (meta.kind) {
|
|
131
|
+
case 'string':
|
|
132
|
+
return fc.string();
|
|
133
|
+
case 'number':
|
|
134
|
+
{
|
|
135
|
+
const {
|
|
136
|
+
min,
|
|
137
|
+
max,
|
|
138
|
+
integer
|
|
139
|
+
} = meta;
|
|
140
|
+
if (!integer) {
|
|
141
|
+
return fc.double({
|
|
142
|
+
max,
|
|
143
|
+
min,
|
|
144
|
+
noDefaultInfinity: true,
|
|
145
|
+
noNaN: true
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// `fc.integer` takes an integral interval within the safe integers, and
|
|
149
|
+
// `numberValue` accepts a domain that is neither: an absent bound is
|
|
150
|
+
// unbounded, where fc.integer's own absent bound is the 32-bit range —
|
|
151
|
+
// so a domain starting past 2^31 - 1 arrived as a maximum below its own
|
|
152
|
+
// minimum and threw instead of generating.
|
|
153
|
+
//
|
|
154
|
+
// Clamping to the safe integers is what keeps the values exact: past
|
|
155
|
+
// 2^53 the integers are no longer adjacent — `1e16 + 1 === 1e16` — so
|
|
156
|
+
// generating there would produce values the schema's own `Number
|
|
157
|
+
// .isInteger` test cannot distinguish. The rounding is belt and braces:
|
|
158
|
+
// `numberValue` already rounds an integer domain's bounds inward, so a
|
|
159
|
+
// fractional one reaches here only from a meta built by hand.
|
|
160
|
+
const low = Math.max(min === undefined ? Number.MIN_SAFE_INTEGER : Math.ceil(min), Number.MIN_SAFE_INTEGER);
|
|
161
|
+
const high = Math.min(max === undefined ? Number.MAX_SAFE_INTEGER : Math.floor(max), Number.MAX_SAFE_INTEGER);
|
|
162
|
+
if (low <= high) {
|
|
163
|
+
return fc.integer({
|
|
164
|
+
max: high,
|
|
165
|
+
min: low
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Nothing survived: the domain lies entirely outside the safe integers
|
|
169
|
+
// (`{integer: true, min: 1e16}`), or holds no integer at all
|
|
170
|
+
// (`{integer: true, min: 0.5, max: 0.7}`) — which `numberValue` refuses
|
|
171
|
+
// to build, so it arrives only from a meta written by hand. Its own
|
|
172
|
+
// rounded bound is the closest thing to a member it has: in domain for
|
|
173
|
+
// the first, and for the second parsed back to the schema's default,
|
|
174
|
+
// which is what every value of an empty domain does.
|
|
175
|
+
return fc.constant(min === undefined ? high : Math.ceil(min));
|
|
176
|
+
}
|
|
177
|
+
case 'boolean':
|
|
178
|
+
return fc.boolean();
|
|
179
|
+
case 'enum':
|
|
180
|
+
return fc.constantFrom(...meta.values);
|
|
181
|
+
case 'array':
|
|
182
|
+
return fc.array(schemaArbitrary(meta.item));
|
|
183
|
+
case 'nullable':
|
|
184
|
+
return fc.option(schemaArbitrary(meta.inner), {
|
|
185
|
+
nil: null
|
|
186
|
+
});
|
|
187
|
+
case 'optional':
|
|
188
|
+
return fc.option(schemaArbitrary(meta.inner), {
|
|
189
|
+
nil: undefined
|
|
190
|
+
});
|
|
191
|
+
case 'union':
|
|
192
|
+
return fc.oneof(...meta.members.map(member => schemaArbitrary(member)));
|
|
193
|
+
case 'aliased':
|
|
194
|
+
{
|
|
195
|
+
// The aliases are legacy input spellings the schema still accepts, so
|
|
196
|
+
// they belong in the generated domain exactly as much as the inner one
|
|
197
|
+
// does — that is what makes them worth stating as data.
|
|
198
|
+
const inner = schemaArbitrary(meta.inner);
|
|
199
|
+
const aliases = Object.keys(meta.aliases);
|
|
200
|
+
// `fc.constantFrom()` with nothing to choose from throws, so a schema
|
|
201
|
+
// that declares no alias generates its inner domain alone — which is
|
|
202
|
+
// what it accepts. An empty table parses fine, so it must generate fine.
|
|
203
|
+
return aliases.length === 0 ? inner : fc.oneof(inner, fc.constantFrom(...aliases));
|
|
204
|
+
}
|
|
205
|
+
case 'transform':
|
|
206
|
+
// What is generated here is serialized *input*, and a transform changes
|
|
207
|
+
// only the output, so the domain to draw from is the inner one — the
|
|
208
|
+
// same answer inheriting the inner meta used to give, now reached
|
|
209
|
+
// deliberately rather than by the transform being invisible.
|
|
210
|
+
return schemaArbitrary(meta.inner);
|
|
211
|
+
case 'raw':
|
|
212
|
+
// The schema deliberately does not describe this value's domain (its
|
|
213
|
+
// owner validates it), so there is nothing to generate from.
|
|
214
|
+
return fc.constant(undefined);
|
|
215
|
+
case 'object':
|
|
216
|
+
{
|
|
217
|
+
const fields = {};
|
|
218
|
+
for (const key of Object.keys(meta.fields)) {
|
|
219
|
+
fields[key] = schemaArbitrary(meta.fields[key]);
|
|
220
|
+
}
|
|
221
|
+
return fc.record(fields);
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
// Unreachable for the schemas `lexical` defines — the switch is
|
|
225
|
+
// exhaustive over SerializationSchemaMeta — but a hand-rolled schema
|
|
226
|
+
// (or one from a newer core) can carry a kind this build has never
|
|
227
|
+
// heard of. Say so, rather than returning undefined and failing inside
|
|
228
|
+
// fast-check's record builder.
|
|
229
|
+
throw new Error(`nodeArbitrary: unsupported serialization schema kind ${String(meta.kind)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export { nodeArbitrary };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as modDev from './LexicalFastCheck.dev.js';
|
|
10
|
+
import * as modProd from './LexicalFastCheck.prod.js';
|
|
11
|
+
const mod = process.env.NODE_ENV !== 'production' ? modDev : modProd;
|
|
12
|
+
export const nodeArbitrary = mod.nodeArbitrary;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
* @flow strict
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {LexicalNode} from 'lexical';
|
|
11
|
+
|
|
12
|
+
// fast-check has no Flow types; the arbitrary is opaque here.
|
|
13
|
+
declare export function nodeArbitrary(
|
|
14
|
+
klass: Class<LexicalNode>,
|
|
15
|
+
): $FlowFixMe;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import*as e from"fast-check";import{getComposedSchemaFields as n,declaredAccepts as r}from"lexical";function t(r){const t=n(r),o={};for(const e of Object.keys(t))o[e]=a(t[e]);return e.record(o,{requiredKeys:[]})}function a(n){const t=function(n){switch(n.kind){case"string":return e.string();case"number":{const{min:r,max:t,integer:a}=n;if(!a)return e.double({max:t,min:r,noDefaultInfinity:!0,noNaN:!0});const o=Math.max(void 0===r?Number.MIN_SAFE_INTEGER:Math.ceil(r),Number.MIN_SAFE_INTEGER),i=Math.min(void 0===t?Number.MAX_SAFE_INTEGER:Math.floor(t),Number.MAX_SAFE_INTEGER);return o<=i?e.integer({max:i,min:o}):e.constant(void 0===r?i:Math.ceil(r))}case"boolean":return e.boolean();case"enum":return e.constantFrom(...n.values);case"array":return e.array(a(n.item));case"nullable":return e.option(a(n.inner),{nil:null});case"optional":return e.option(a(n.inner),{nil:void 0});case"union":return e.oneof(...n.members.map(e=>a(e)));case"aliased":{const r=a(n.inner),t=Object.keys(n.aliases);return 0===t.length?r:e.oneof(r,e.constantFrom(...t))}case"transform":return a(n.inner);case"raw":return e.constant(void 0);case"object":{const r={};for(const e of Object.keys(n.fields))r[e]=a(n.fields[e]);return e.record(r)}default:throw new Error(`nodeArbitrary: unsupported serialization schema kind ${String(n.kind)}`)}}(n.meta),s=r(n);if(void 0===s)return t;const c=e=>s.call(n,e);let m=o.get(n);if(void 0===m&&(m=e.sample(t,{numRuns:i,seed:0}).some(c),o.set(n,m)),!m)throw new Error(`nodeArbitrary: a schema of kind ${String(n.meta.kind)} declares a membership predicate that rejected all ${i} values its metadata generates, so no example can be drawn for it. Widen the predicate, or describe the narrowed domain in the schema's metadata.`);return t.filter(c)}const o=new WeakMap,i=1e3;export{t as nodeArbitrary};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
import type { Klass, LexicalNode, LexicalSchemaInput } from 'lexical';
|
|
9
|
+
import * as fc from 'fast-check';
|
|
10
|
+
/**
|
|
11
|
+
* Derive a fast-check arbitrary for the node-specific properties of `klass`'s
|
|
12
|
+
* serialized JSON, composing the schemas it inherits
|
|
13
|
+
* — so an element-based node generates the properties it gets from ElementNode
|
|
14
|
+
* as well as its own. Spread the result with `type`/`version` to feed
|
|
15
|
+
* `importJSON`. Because each property's schema is the same one the node uses
|
|
16
|
+
* to parse its JSON, the generated values are exactly what the node's parser
|
|
17
|
+
* accepts — a single declaration powers both parsing and example generation.
|
|
18
|
+
*
|
|
19
|
+
* Every property is generated independently as present or absent
|
|
20
|
+
* (`requiredKeys: []`), because that is the domain the parsers actually face:
|
|
21
|
+
* a `SerializedPartial` may omit any node-specific property — older documents
|
|
22
|
+
* predate a property, and a compact export omits one whose value equals its
|
|
23
|
+
* default. Generating complete records only would leave the defaulting path
|
|
24
|
+
* (the whole point of a schema carrying a `defaultValue`) untested.
|
|
25
|
+
*
|
|
26
|
+
* The values are typed as what the class's schemas *accept*, composed across
|
|
27
|
+
* its `$config` chain — not as what they parse to. The two differ wherever a
|
|
28
|
+
* schema reads more than it writes, and this generator produces exactly that
|
|
29
|
+
* difference: an `aliasedValue`'s legacy spellings are in the generated domain
|
|
30
|
+
* (see the `aliased` case below), so a type describing the parsed output would
|
|
31
|
+
* be wrong about the values it hands back.
|
|
32
|
+
*/
|
|
33
|
+
export declare function nodeArbitrary<T extends LexicalNode>(klass: Klass<T>): fc.Arbitrary<LexicalSchemaInput<T>>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// You are seeing this declaration file because your TypeScript cannot read
|
|
10
|
+
// Lexical's types through the package.json "exports" field.
|
|
11
|
+
//
|
|
12
|
+
// Lexical requires TypeScript >= 5.2 with "moduleResolution" set to
|
|
13
|
+
// "bundler", "node16", or "nodenext". To fix this:
|
|
14
|
+
// 1. Upgrade TypeScript to >= 5.2.
|
|
15
|
+
// 2. In tsconfig.json set "moduleResolution" to "bundler" (recommended for
|
|
16
|
+
// apps/bundlers) or "node16" / "nodenext", and a matching "module".
|
|
17
|
+
import 'Lexical requires TypeScript >=5.2 with moduleResolution bundler, node16, or nodenext';
|
|
18
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Placeholder published to claim the npm name during trusted publishing setup. Do not install.",
|
|
3
|
-
"homepage": "https://lexical.dev",
|
|
4
|
-
"license": "MIT",
|
|
5
2
|
"name": "@lexical/fast-check",
|
|
3
|
+
"description": "fast-check arbitraries derived from the JSON schemas Lexical nodes declare on $config, for property-based tests.",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"lexical",
|
|
6
|
+
"editor",
|
|
7
|
+
"rich-text",
|
|
8
|
+
"fast-check",
|
|
9
|
+
"property-based testing",
|
|
10
|
+
"serialization"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"version": "0.51.1-nightly.20260918.0",
|
|
14
|
+
"main": "./dist/LexicalFastCheck.js",
|
|
15
|
+
"types": "./dist/typescript-too-old.d.ts",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"lexical": "0.51.1-nightly.20260918.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"fast-check": ">=4.0.0",
|
|
21
|
+
"typescript": ">=5.2"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"typescript": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"fast-check": "^4.8.0"
|
|
30
|
+
},
|
|
6
31
|
"repository": {
|
|
7
32
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/facebook/lexical.git"
|
|
33
|
+
"url": "git+https://github.com/facebook/lexical.git",
|
|
34
|
+
"directory": "packages/lexical-fast-check"
|
|
35
|
+
},
|
|
36
|
+
"module": "./dist/LexicalFastCheck.js",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"source": "./src/index.ts",
|
|
41
|
+
"types@<5.2": "./dist/typescript-too-old.d.ts",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"development": "./dist/LexicalFastCheck.dev.js",
|
|
44
|
+
"production": "./dist/LexicalFastCheck.prod.js",
|
|
45
|
+
"default": "./dist/LexicalFastCheck.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"dist",
|
|
50
|
+
"src",
|
|
51
|
+
"!src/__tests__",
|
|
52
|
+
"!src/__bench__",
|
|
53
|
+
"!src/__mocks__",
|
|
54
|
+
"!src/**/*.test.ts",
|
|
55
|
+
"!src/**/*.test.tsx",
|
|
56
|
+
"!src/**/*.bench.ts",
|
|
57
|
+
"!src/**/*.bench.tsx",
|
|
58
|
+
"README.md",
|
|
59
|
+
"LICENSE"
|
|
60
|
+
],
|
|
61
|
+
"typesVersions": {
|
|
62
|
+
"*": {
|
|
63
|
+
"*": [
|
|
64
|
+
"./dist/typescript-too-old.d.ts"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
9
67
|
},
|
|
10
|
-
"
|
|
11
|
-
}
|
|
68
|
+
"type": "module"
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AnySerializationSchema,
|
|
11
|
+
Klass,
|
|
12
|
+
LexicalNode,
|
|
13
|
+
LexicalSchemaInput,
|
|
14
|
+
SerializationSchemaMeta,
|
|
15
|
+
} from 'lexical';
|
|
16
|
+
|
|
17
|
+
import * as fc from 'fast-check';
|
|
18
|
+
// `declaredAccepts` is `@internal`, re-exported from the package entry the way
|
|
19
|
+
// `getComposedSchemaFields` is: a deep `lexical/src/*` import resolves only
|
|
20
|
+
// through this repo's tsconfig paths, so it does not build and would give a
|
|
21
|
+
// consumer a second copy of the module — and a second `DERIVED_ACCEPTS`, which
|
|
22
|
+
// is module-scope state the answer depends on.
|
|
23
|
+
import {declaredAccepts, getComposedSchemaFields} from 'lexical';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Derive a fast-check arbitrary for the node-specific properties of `klass`'s
|
|
27
|
+
* serialized JSON, composing the schemas it inherits
|
|
28
|
+
* — so an element-based node generates the properties it gets from ElementNode
|
|
29
|
+
* as well as its own. Spread the result with `type`/`version` to feed
|
|
30
|
+
* `importJSON`. Because each property's schema is the same one the node uses
|
|
31
|
+
* to parse its JSON, the generated values are exactly what the node's parser
|
|
32
|
+
* accepts — a single declaration powers both parsing and example generation.
|
|
33
|
+
*
|
|
34
|
+
* Every property is generated independently as present or absent
|
|
35
|
+
* (`requiredKeys: []`), because that is the domain the parsers actually face:
|
|
36
|
+
* a `SerializedPartial` may omit any node-specific property — older documents
|
|
37
|
+
* predate a property, and a compact export omits one whose value equals its
|
|
38
|
+
* default. Generating complete records only would leave the defaulting path
|
|
39
|
+
* (the whole point of a schema carrying a `defaultValue`) untested.
|
|
40
|
+
*
|
|
41
|
+
* The values are typed as what the class's schemas *accept*, composed across
|
|
42
|
+
* its `$config` chain — not as what they parse to. The two differ wherever a
|
|
43
|
+
* schema reads more than it writes, and this generator produces exactly that
|
|
44
|
+
* difference: an `aliasedValue`'s legacy spellings are in the generated domain
|
|
45
|
+
* (see the `aliased` case below), so a type describing the parsed output would
|
|
46
|
+
* be wrong about the values it hands back.
|
|
47
|
+
*/
|
|
48
|
+
export function nodeArbitrary<T extends LexicalNode>(
|
|
49
|
+
klass: Klass<T>,
|
|
50
|
+
): fc.Arbitrary<LexicalSchemaInput<T>> {
|
|
51
|
+
const fields = getComposedSchemaFields(klass);
|
|
52
|
+
const record: {[key: string]: fc.Arbitrary<unknown>} = {};
|
|
53
|
+
for (const key of Object.keys(fields)) {
|
|
54
|
+
record[key] = schemaArbitrary(fields[key]);
|
|
55
|
+
}
|
|
56
|
+
// Both sides now read the same `$config` chain — the value through
|
|
57
|
+
// `getComposedSchemaFields`, the type through `LexicalSchemaInput` — so the
|
|
58
|
+
// cast is asserting that two walks of one declaration agree. Where they can
|
|
59
|
+
// still part company is a config that names no `extends`: the runtime walk
|
|
60
|
+
// falls back to the prototype parent, the type walk has nothing to follow
|
|
61
|
+
// and stops, and the type comes back missing the properties the value
|
|
62
|
+
// carries. Naming `extends` is what keeps them together.
|
|
63
|
+
//
|
|
64
|
+
// What the cast is *not* covering is a flat NodeState: this generates the
|
|
65
|
+
// JSON that carries one, and `LexicalSchemaInput` types those keys `unknown`
|
|
66
|
+
// for the same reason `StateConfig.parse` takes `unknown` — nothing records
|
|
67
|
+
// what a state accepted on the way in. It said `V` until recently, which the
|
|
68
|
+
// cast would have asserted away: a `string`-to-`Date` state generated strings
|
|
69
|
+
// that a caller could have read as dates.
|
|
70
|
+
return fc.record(record, {requiredKeys: []}) as fc.Arbitrary<
|
|
71
|
+
LexicalSchemaInput<T>
|
|
72
|
+
>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The arbitrary for one schema: what its metadata describes, narrowed to what
|
|
77
|
+
* the schema itself admits.
|
|
78
|
+
*
|
|
79
|
+
* The metadata describes what the *combinator* accepts, and a schema whose
|
|
80
|
+
* author installed a membership predicate of its own admits less than that —
|
|
81
|
+
* a `nullable(stringValue())` that takes only `#`-prefixed strings still
|
|
82
|
+
* carries `nullable` metadata, so generating from the metadata alone produced
|
|
83
|
+
* values it declines. A round-trip property then failed on values this claimed
|
|
84
|
+
* were in domain, or passed while a sibling union member silently rewrote
|
|
85
|
+
* them.
|
|
86
|
+
*
|
|
87
|
+
* Filtered rather than generated differently, because the predicate is an
|
|
88
|
+
* opaque function: what it admits can only be discovered by asking it.
|
|
89
|
+
*/
|
|
90
|
+
function schemaArbitrary(
|
|
91
|
+
schema: AnySerializationSchema,
|
|
92
|
+
): fc.Arbitrary<unknown> {
|
|
93
|
+
const arbitrary = metaArbitrary(schema.meta);
|
|
94
|
+
const accepts = declaredAccepts(schema);
|
|
95
|
+
if (accepts === undefined) {
|
|
96
|
+
return arbitrary;
|
|
97
|
+
}
|
|
98
|
+
const claims = (value: unknown) => accepts.call(schema, value);
|
|
99
|
+
// `filter` retries without a cap, so a predicate the metadata's domain
|
|
100
|
+
// cannot satisfy makes generation spin rather than fail — synchronously, so
|
|
101
|
+
// a test runner's own timeout cannot interrupt it and nothing names the
|
|
102
|
+
// property responsible. A `rawValue()` narrowed to strings is exactly that:
|
|
103
|
+
// its metadata describes no domain, so `raw` generates `undefined` alone and
|
|
104
|
+
// no draw is ever accepted. Probing first turns the hang into a diagnostic.
|
|
105
|
+
//
|
|
106
|
+
// Asked once per schema: the answer cannot change, and this recurses, so a
|
|
107
|
+
// node with five narrowed properties otherwise burned five thousand
|
|
108
|
+
// discarded draws every time its arbitrary was built.
|
|
109
|
+
let satisfiable = SATISFIABLE.get(schema);
|
|
110
|
+
if (satisfiable === undefined) {
|
|
111
|
+
satisfiable = fc
|
|
112
|
+
.sample(arbitrary, {numRuns: FILTER_PROBE_RUNS, seed: 0})
|
|
113
|
+
.some(claims);
|
|
114
|
+
SATISFIABLE.set(schema, satisfiable);
|
|
115
|
+
}
|
|
116
|
+
if (!satisfiable) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`nodeArbitrary: a schema of kind ${String(
|
|
119
|
+
schema.meta.kind,
|
|
120
|
+
)} declares a membership predicate that rejected all ${FILTER_PROBE_RUNS} values its metadata generates, so no example can be drawn for it. Widen the predicate, or describe the narrowed domain in the schema's metadata.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return arbitrary.filter(claims);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether a schema's declared predicate can be satisfied over its metadata's
|
|
128
|
+
* domain — a property of the schema alone, so it is asked once, the same
|
|
129
|
+
* reason the core memoizes `$isCatchAll`.
|
|
130
|
+
*/
|
|
131
|
+
const SATISFIABLE = new WeakMap<AnySerializationSchema, boolean>();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How many draws {@link schemaArbitrary} tries before calling a declared
|
|
135
|
+
* predicate unsatisfiable over its metadata's domain. Large enough that a
|
|
136
|
+
* predicate accepting a reasonable fraction is never mistaken for one that
|
|
137
|
+
* accepts nothing, small enough to cost nothing next to a property run.
|
|
138
|
+
*/
|
|
139
|
+
const FILTER_PROBE_RUNS = 1000;
|
|
140
|
+
|
|
141
|
+
function metaArbitrary(meta: SerializationSchemaMeta): fc.Arbitrary<unknown> {
|
|
142
|
+
switch (meta.kind) {
|
|
143
|
+
case 'string':
|
|
144
|
+
return fc.string();
|
|
145
|
+
case 'number': {
|
|
146
|
+
const {min, max, integer} = meta;
|
|
147
|
+
if (!integer) {
|
|
148
|
+
return fc.double({max, min, noDefaultInfinity: true, noNaN: true});
|
|
149
|
+
}
|
|
150
|
+
// `fc.integer` takes an integral interval within the safe integers, and
|
|
151
|
+
// `numberValue` accepts a domain that is neither: an absent bound is
|
|
152
|
+
// unbounded, where fc.integer's own absent bound is the 32-bit range —
|
|
153
|
+
// so a domain starting past 2^31 - 1 arrived as a maximum below its own
|
|
154
|
+
// minimum and threw instead of generating.
|
|
155
|
+
//
|
|
156
|
+
// Clamping to the safe integers is what keeps the values exact: past
|
|
157
|
+
// 2^53 the integers are no longer adjacent — `1e16 + 1 === 1e16` — so
|
|
158
|
+
// generating there would produce values the schema's own `Number
|
|
159
|
+
// .isInteger` test cannot distinguish. The rounding is belt and braces:
|
|
160
|
+
// `numberValue` already rounds an integer domain's bounds inward, so a
|
|
161
|
+
// fractional one reaches here only from a meta built by hand.
|
|
162
|
+
const low = Math.max(
|
|
163
|
+
min === undefined ? Number.MIN_SAFE_INTEGER : Math.ceil(min),
|
|
164
|
+
Number.MIN_SAFE_INTEGER,
|
|
165
|
+
);
|
|
166
|
+
const high = Math.min(
|
|
167
|
+
max === undefined ? Number.MAX_SAFE_INTEGER : Math.floor(max),
|
|
168
|
+
Number.MAX_SAFE_INTEGER,
|
|
169
|
+
);
|
|
170
|
+
if (low <= high) {
|
|
171
|
+
return fc.integer({max: high, min: low});
|
|
172
|
+
}
|
|
173
|
+
// Nothing survived: the domain lies entirely outside the safe integers
|
|
174
|
+
// (`{integer: true, min: 1e16}`), or holds no integer at all
|
|
175
|
+
// (`{integer: true, min: 0.5, max: 0.7}`) — which `numberValue` refuses
|
|
176
|
+
// to build, so it arrives only from a meta written by hand. Its own
|
|
177
|
+
// rounded bound is the closest thing to a member it has: in domain for
|
|
178
|
+
// the first, and for the second parsed back to the schema's default,
|
|
179
|
+
// which is what every value of an empty domain does.
|
|
180
|
+
return fc.constant(min === undefined ? high : Math.ceil(min));
|
|
181
|
+
}
|
|
182
|
+
case 'boolean':
|
|
183
|
+
return fc.boolean();
|
|
184
|
+
case 'enum':
|
|
185
|
+
return fc.constantFrom(...meta.values);
|
|
186
|
+
case 'array':
|
|
187
|
+
return fc.array(schemaArbitrary(meta.item));
|
|
188
|
+
case 'nullable':
|
|
189
|
+
return fc.option(schemaArbitrary(meta.inner), {nil: null});
|
|
190
|
+
case 'optional':
|
|
191
|
+
return fc.option(schemaArbitrary(meta.inner), {nil: undefined});
|
|
192
|
+
case 'union':
|
|
193
|
+
return fc.oneof(...meta.members.map(member => schemaArbitrary(member)));
|
|
194
|
+
case 'aliased': {
|
|
195
|
+
// The aliases are legacy input spellings the schema still accepts, so
|
|
196
|
+
// they belong in the generated domain exactly as much as the inner one
|
|
197
|
+
// does — that is what makes them worth stating as data.
|
|
198
|
+
const inner = schemaArbitrary(meta.inner);
|
|
199
|
+
const aliases = Object.keys(meta.aliases);
|
|
200
|
+
// `fc.constantFrom()` with nothing to choose from throws, so a schema
|
|
201
|
+
// that declares no alias generates its inner domain alone — which is
|
|
202
|
+
// what it accepts. An empty table parses fine, so it must generate fine.
|
|
203
|
+
return aliases.length === 0
|
|
204
|
+
? inner
|
|
205
|
+
: fc.oneof(inner, fc.constantFrom(...aliases));
|
|
206
|
+
}
|
|
207
|
+
case 'transform':
|
|
208
|
+
// What is generated here is serialized *input*, and a transform changes
|
|
209
|
+
// only the output, so the domain to draw from is the inner one — the
|
|
210
|
+
// same answer inheriting the inner meta used to give, now reached
|
|
211
|
+
// deliberately rather than by the transform being invisible.
|
|
212
|
+
return schemaArbitrary(meta.inner);
|
|
213
|
+
case 'raw':
|
|
214
|
+
// The schema deliberately does not describe this value's domain (its
|
|
215
|
+
// owner validates it), so there is nothing to generate from.
|
|
216
|
+
return fc.constant(undefined);
|
|
217
|
+
case 'object': {
|
|
218
|
+
const fields: {[key: string]: fc.Arbitrary<unknown>} = {};
|
|
219
|
+
for (const key of Object.keys(meta.fields)) {
|
|
220
|
+
fields[key] = schemaArbitrary(meta.fields[key]);
|
|
221
|
+
}
|
|
222
|
+
return fc.record(fields);
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
// Unreachable for the schemas `lexical` defines — the switch is
|
|
226
|
+
// exhaustive over SerializationSchemaMeta — but a hand-rolled schema
|
|
227
|
+
// (or one from a newer core) can carry a kind this build has never
|
|
228
|
+
// heard of. Say so, rather than returning undefined and failing inside
|
|
229
|
+
// fast-check's record builder.
|
|
230
|
+
throw new Error(
|
|
231
|
+
`nodeArbitrary: unsupported serialization schema kind ${String(
|
|
232
|
+
(meta as {kind?: unknown}).kind,
|
|
233
|
+
)}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|