// fixtures/autoformat.test.mjs — the input rules, under plain Node. // // No DOM and no browser: ProseMirror's state, model and inputrules // packages are all headless, so a rule can be fired against a real // EditorState and the resulting document inspected. Only // prosemirror-view needs a browser, and nothing here touches it. // // That matters because Chromium will not start in this project's // sandbox at all, so a rule tested only through the browser drive is a // rule tested nowhere most of the time. These run in the gate. import { test } from 'node:test' import assert from 'node:assert/strict' import { Schema } from 'prosemirror-model' import { EditorState } from 'prosemirror-state' import { schema as basic } from 'prosemirror-schema-basic' import { addListNodes } from 'prosemirror-schema-list' import { undoInputRule } from 'prosemirror-inputrules' import { autoformatRules, PATTERNS } from './autoformat.mjs' const schema = new Schema({ nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'), marks: basic.spec.marks, }) // A minimal stand-in for an EditorView. prosemirror-inputrules' own // handleTextInput reads state, calls dispatch, and asks for other // plugins' props through someProp — none of which needs a DOM. Written // out rather than mocked with a library because what it has to provide // is exactly these three things, and a reader should be able to see // that. function fakeView(state) { return { state, dispatch(tr) { this.state = this.state.apply(tr) }, someProp() { return undefined }, } } // type runs one text input through the plugin, the way a keystroke // would: the document already contains everything before the final // character, and that character is what is "typed". // // Returns the view, so a test can go on to press Backspace on it. function type(text) { const before = text.slice(0, -1) const last = text.slice(-1) const plugin = autoformatRules(schema) const doc = schema.node('doc', null, [ before ? schema.node('paragraph', null, [schema.text(before)]) : schema.node('paragraph'), ]) let state = EditorState.create({ doc, schema, plugins: [plugin] }) // Put the caret at the end of the paragraph. state = state.apply(state.tr.setSelection(state.selection.constructor.atEnd(doc))) const view = fakeView(state) const end = view.state.selection.from plugin.props.handleTextInput(view, end, end, last) return view } function firstChildType(view) { return view.state.doc.firstChild.type.name } test('"- " starts a bulleted list', () => { assert.equal(firstChildType(type('- ')), 'bullet_list') }) test('"* " and "+ " start one too', () => { assert.equal(firstChildType(type('* ')), 'bullet_list') assert.equal(firstChildType(type('+ ')), 'bullet_list') }) test('"1. " starts a numbered list', () => { assert.equal(firstChildType(type('1. ')), 'ordered_list') }) test('"> " starts a quote', () => { assert.equal(firstChildType(type('> ')), 'blockquote') }) test('"# " makes a heading, and "### " makes a level-3 one', () => { const one = type('# ') assert.equal(firstChildType(one), 'heading') assert.equal(one.state.doc.firstChild.attrs.level, 1) const three = type('### ') assert.equal(firstChildType(three), 'heading') assert.equal(three.state.doc.firstChild.attrs.level, 3) }) // Backspace immediately afterwards is the whole reason autoformat is // tolerable rather than infuriating: it must give back the characters // that were typed, not merely undo into an empty paragraph. test('Backspace right after a rule gives back the typed characters', () => { const view = type('- ') assert.equal(firstChildType(view), 'bullet_list') const ran = undoInputRule(view.state, (tr) => view.dispatch(tr)) assert.equal(ran, true, 'undoInputRule did not fire after a rule') assert.equal(firstChildType(view), 'paragraph') assert.equal(view.state.doc.firstChild.textContent, '- ') }) // And away from a rule it must decline, so baseKeymap's own Backspace // still deletes a character. A version of this that always returned // true would eat every Backspace in the document. test('Backspace away from a rule declines, leaving Backspace alone', () => { const doc = schema.node('doc', null, [ schema.node('paragraph', null, [schema.text('ordinary text')]), ]) const plugin = autoformatRules(schema) let state = EditorState.create({ doc, schema, plugins: [plugin] }) state = state.apply(state.tr.setSelection(state.selection.constructor.atEnd(doc))) assert.equal(undoInputRule(state, () => {}), false) }) // The negative cases are the ones people actually notice. A rule that // fires mid-sentence, or on a decimal, is worse than one that never // fires at all. test('the patterns do not fire where they must not', () => { // A hyphen inside a word, and a bullet opener that is not at a block // start, are both handled by ProseMirror matching only the text // before the caret WITHIN the current textblock — but the patterns // themselves must also refuse the shapes below. assert.equal(PATTERNS.BULLET.test('a- '), false) assert.equal(PATTERNS.BULLET.test('-x '), false) assert.equal(PATTERNS.BULLET.test('--- '), false) // "3.14" must stay a number. The pattern requires the space. assert.equal(PATTERNS.ORDERED.test('3.14'), false) assert.equal(PATTERNS.ORDERED.test('3. '), true) // Four hashes is not a heading this build offers — the Style control // shows three levels, and a rule reaching a level the control cannot // report would be a way to make the toolbar lie. assert.equal(PATTERNS.HEADING.test('#### '), false) assert.equal(PATTERNS.HEADING.test('### '), true) // A quote marker needs its space too. assert.equal(PATTERNS.QUOTE.test('>x '), false) assert.equal(PATTERNS.QUOTE.test('> '), true) }) // Typing "7. " starts the list at 1, not at 7 — deliberately, because // core/src/fragment.rs does not read the `order` attribute, so setting // it would make the editor disagree with the read view and the PDF with // nothing reporting it. See autoformat.mjs's ORDERED comment. // // Asserted rather than left as a comment: the day `order` becomes real // in fragment.rs, this test fails and points at the decision to revisit // instead of letting the two quietly diverge. test('a numbered list always starts at 1 while the renderer ignores order', () => { const view = type('7. ') assert.equal(firstChildType(view), 'ordered_list') assert.equal(view.state.doc.firstChild.attrs.order, 1) })