Skip to main content

@lexical/fast-check

See API Documentation

This package derives fast-check arbitraries from the serialization schemas Lexical nodes declare on $config, so property-based tests generate exactly the serialized JSON a node's parser accepts.

Install it alongside fast-check, which is a peer dependency:

npm install --save-dev @lexical/fast-check fast-check

Usage

nodeArbitrary(NodeClass) generates the node-specific properties of a serialized node, composing the schemas the class inherits (an element-based node also gets direction/format/indent/… from ElementNode). Every property is generated independently as present or absent, because that is the domain parsers actually face: an older document predates a property, and a compact export omits one whose value equals its default.

The values are typed as the parse shape of the class's own serialized type — LexicalParseJSON<SerializedMyNode> for a MyNode that declares exportJSON(): SerializedMyNode — which is what its updateFromJSON accepts, so they can be handed to it without a cast.

import {nodeArbitrary} from '@lexical/fast-check';
import * as fc from 'fast-check';

fc.assert(
fc.property(nodeArbitrary(MyNode), props => {
const json = {...props, type: 'my-node'};
// importing and re-exporting is a fixed point
expect(exportOf(importOf(json))).toEqual(exportOf(importOf(exportOf(importOf(json)))));
}),
);

nodeArbitrary reads the class's $config, so no editor or node instance is required — but resolving it injects the class's synthesized statics and compiles its accessor tables, so a class whose schema names an accessor it does not have throws here. To inspect what a node declares without generating anything, call getComposedSchemaFields(NodeClass) from lexical directly.

Example: proving a clone carries your node's properties

A custom node has to copy its own properties in afterCloneFrom, which Lexical calls whenever getWritable() clones the node. Declaring a new property and forgetting to copy it there is a silent bug: the field still exists on the clone because the constructor set it, so nothing throws — the node just reverts that one property to its default the next time anything writes to it.

This is a good fit for a generated test, because a hand-written fixture tends to use default values, and a dropped property compares equal to its default. The test looks like it passed precisely when the bug is invisible. nodeArbitrary draws from the schema's own domain instead, so the values are in-domain and mostly not the default:

import {nodeArbitrary} from '@lexical/fast-check';
import * as fc from 'fast-check';
import {
$create,
$createParagraphNode,
$getNodeByKey,
$getRoot,
createEditor,
TextNode,
} from 'lexical';

test('a clone carries every property TextNode declares', () => {
fc.assert(
fc.property(nodeArbitrary(TextNode), props => {
const editor = createEditor({
nodes: [TextNode],
onError: e => {
throw e;
},
});
let key, before, original;

// A node is only cloned *across* updates. Within one, getWritable()
// hands back the same object, so a single-update test never reaches
// afterCloneFrom at all.
editor.update(() => {
const node = $create(TextNode);
// Everything but `text` is used exactly as generated. An empty
// TextNode is removed by normalization between the two updates, so
// there would be nothing left to clone; a leaf also cannot be a child
// of the root, hence the paragraph.
node.updateFromJSON({...props, text: 'x'});
$getRoot().clear().append($createParagraphNode().append(node));
[key, original, before] = [node.getKey(), node, node.exportJSON()];
}, {discrete: true});

editor.update(() => {
const writable = $getNodeByKey(key).getWritable();
expect(writable).not.toBe(original); // a clone really happened
expect(writable.exportJSON()).toEqual(before);
}, {discrete: true});
}),
);
});

When it fails, fast-check shrinks to the smallest value that exposes it, which names the property for you. Deleting this.__style = prevNode.__style from TextNode.afterCloneFrom reports:

Counterexample: [{"style":" "}]

- "style": " ",
+ "style": "",

A single space — the smallest style that is not the default '', and the only kind of value that can catch this.