@lexical/fast-check 0.0.0-bootstrap.0 → 0.51.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 CHANGED
@@ -1,3 +1,121 @@
1
- # @lexical/fast-check
1
+ # `@lexical/fast-check`
2
2
 
3
- This 0.0.0-bootstrap.0 placeholder was published only to claim the npm package name so trusted publishing can be configured on npmjs.com. It contains no code; a future release will replace it.
3
+ [![See API Documentation](https://lexical.dev/img/see-api-documentation.svg)](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};
@@ -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.0",
14
+ "main": "./dist/LexicalFastCheck.js",
15
+ "types": "./dist/typescript-too-old.d.ts",
16
+ "dependencies": {
17
+ "lexical": "0.51.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
- "version": "0.0.0-bootstrap.0"
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
+ }