Skip to Content
Testing

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 your devDependencies before importing @stesura/core/testing. Entry points that don’t import this subpath never load it.

ExportPurpose
tbprosemirror-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 / getAllSectionsSection 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, make3x3StateTable nodes and states.
getTableNode, rowCount, colCountRead 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); // boolean

Composing 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 package

Inside a package:

cd packages/core npm run test npm run test:watch # watch mode

Next 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.
Last updated on