Testing
EditorState, transactions and commands are plain values and functions, so
most editor tests need no DOM, React or browser. The repo uses Vitest, with
the shared config in @stesura/test-config.
The @stesura/core/testing helpers
Core exports the builders its own specs use on the /testing subpath.
Peer dependency: the subpath is built on
prosemirror-test-builder, an optional peer dependency. Add it to yourdevDependenciesbefore importing@stesura/core/testing. Entry points that don’t import this subpath never load it.
| Export | Purpose |
|---|---|
tb | prosemirror-test-builder builders for the full core schema. Every node and mark name (tb.paragraph, tb.strong, …), plus short aliases: tb.p, tb.h, tb.t/tb.tr/tb.tc, tb.img, tb.co/tb.cc (comment open/close), and so on. <a>-style tags in text record positions. |
testDoc(...content) | A schema-checked doc with content in one section’s section_content, plus the footnotes container. |
testSection(content, attrs?) | One section (header, content, footer) around a content array. |
checkedDoc(doc) / uncheckedTestDoc(...content) | Run doc.check() on a hand-built doc, or build a testDoc without the check (for specs that need an invalid doc on purpose). |
selectionInContent(doc) / findFirstInContent(doc, typeName) | A cursor at the first textblock of the body, or the first node of a type in the body. These skip the empty paragraphs in the section header and footer. |
singleSectionState(content?) / twoSectionState() | Ready-made states. The first puts content (default "hello") in one paragraph with the cursor at its start. The second has two sections, with the cursor at the end of the second one’s paragraph. Neither installs plugins. |
getCursorSection / getAllSections | Section lookups for assertions. |
tempEditor({ doc?, plugins? }) | A real EditorView on a detached element, so appendTransaction, view hooks and DOM handlers run. It needs a DOM environment such as jsdom. Call destroy() when done. |
makeCell, makeCellWithText, makeRow, makeTable, stateWithTable, stateWithCursorAt(table, row, col), make2x2State, make3x3State | Table nodes and states. |
getTableNode, rowCount, colCount | Read the table around the selection. They throw when the selection isn’t in a table. |
stateWithSelectedImage(attrs?) | An image selected with a NodeSelection. |
makeLinkEditorState(doc, from, to?) | A state with the link plugin and the given selection. |
getCommentAttrs(state) | Attrs of the last comment_open and comment_close in the doc (meant for single-comment fixtures). |
collectAttr(doc, attrName, predicate?) | { pos, value } for every node (default: blocks) that declares the attr. |
Use testDoc rather than hardcoded positions. Every section starts with a
header that holds an empty paragraph, so position 1 isn’t in the body.
Testing commands
Commands have the ProseMirror signature (state, dispatch?, view?) => boolean.
Build a state, run the command with a dispatch that applies the transaction,
then assert on the result:
import { describe, it, expect } from "vitest";
import { EditorState, TextSelection } from "prosemirror-state";
import { tb, testDoc } from "@stesura/core/testing";
import { toggleBold } from "@stesura/core/commands";
describe("toggleBold", () => {
it("bolds the selection", () => {
const doc = testDoc(tb.p!("<a>hello<b> world"));
let state = EditorState.create({
doc,
selection: TextSelection.create(doc, doc.tag.a!, doc.tag.b!),
});
const handled = toggleBold(state, (tr) => {
state = state.apply(tr);
});
expect(handled).toBe(true);
expect(state.doc.rangeHasMark(doc.tag.a!, doc.tag.b!, state.schema.marks.strong!)).toBe(true);
});
});state.apply skips appendTransaction, so plugins that react to a transaction
(numbering, unique ids, healing) don’t run. Use tempEditor when a test depends
on them.
Dry-run (can-apply check)
Call a command without dispatch to check whether it applies, without building a
transaction:
const canApply = myCommand(state); // booleanComposing operations
composeCommands runs several commands against one transaction and dispatches
once, so the group undoes in one step. sequenceCommands is the
all-or-nothing variant: it stops at the first command that returns false and
dispatches nothing.
import { composeCommands, toggleCounter, setTextAlign } from "@stesura/core/commands";
composeCommands(
toggleCounter({ buttonType: "number" }),
setTextAlign("center")
)(state, (tr) => {
state = state.apply(tr); // one transaction carrying both operations
});Testing plugins
Read plugin state through its key. Install only the plugins under test:
import { EditorState } from "prosemirror-state";
import { tb, testDoc } from "@stesura/core/testing";
import { searchPlugin, searchPluginKey, SearchQuery } from "@stesura/core/plugins";
import { enableSearchState } from "@stesura/core/commands";
import { setSearchState } from "@stesura/core/helpers";
const buildState = () =>
EditorState.create({ doc: testDoc(tb.p!("hello world")), plugins: [searchPlugin()] });
it("enableSearchState opens search", () => {
let state = buildState();
enableSearchState(state, (tr) => {
state = state.apply(tr);
});
expect(searchPluginKey.getState(state)?.enabled).toBe(true);
});
it("setSearchState sets the query", () => {
let state = buildState();
const query = new SearchQuery({ search: "hello" });
state = state.apply(setSearchState(state.tr, query));
expect(searchPluginKey.getState(state)?.query).toBe(query);
});For the full stack, stesuraPlugins(schema, editorId) returns every core
plugin. Build the doc from the same schema: stesuraSchema() and
createInitialDoc(schema).
Testing custom plugins
Write the plugin in isolation, supply a minimal schema and initial state, then drive it with transactions.
import { Plugin, PluginKey, EditorState } from "prosemirror-state";
import { schema as minSchema } from "prosemirror-schema-basic";
const wordCountKey = new PluginKey<number>("wordCount");
const wordCountPlugin = new Plugin({
key: wordCountKey,
state: {
init: () => 0,
apply(tr, _, __, newState) {
let count = 0;
newState.doc.descendants((node) => {
if (node.isText && node.text) count += node.text.split(/\s+/).filter(Boolean).length;
});
return count;
},
},
});
it("counts words", () => {
const state = EditorState.create({
schema: minSchema,
plugins: [wordCountPlugin],
});
const tr = state.tr.insertText("hello world", 1);
const next = state.apply(tr);
expect(wordCountKey.getState(next)).toBe(2);
});Testing schema structure
import { stesuraSchema } from "@stesura/core/schema";
it("has the core nodes and marks", () => {
const schema = stesuraSchema();
expect(schema.marks.strong).toBeDefined();
expect(schema.nodes.section).toBeDefined();
});Testing collaborative behaviour
For plugin and command tests you rarely need a collab backend at all — a plain
state from stesuraPlugins or @stesura/core/testing behaves identically.
When you do need one, build the state with composeCollabPlugins({ version, plugins })
from @stesura/collab-adapter, against an in-memory authority:
createCollabBackend with memoryStore from @stesura/collab-adapter-server
and fakeBroadcast from @stesura/collab-adapter-server/testing. See
Collaboration.
Running tests
The repo uses npm and Turborepo. From the repo root:
npm run test # all packages
npm run test -- --filter=@stesura/core # one packageInside a package:
cd packages/core
npm run test
npm run test:watch # watch modeNext steps
- ProseMirror API — state, transactions, and commands.
- Plugins — plugin keys for reading state in tests.
- Contributing — how to run the full test suite before opening a PR.