vitest-prosemirror 0.3.2 → 0.4.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.
@@ -1,44 +1,42 @@
1
1
  import 'vitest';
2
+ import { EditorState } from 'prosemirror-state';
2
3
  import { Node as Node_2 } from 'prosemirror-model';
3
4
  import { Plugin as Plugin_2 } from 'prosemirror-state';
4
- import { Schema } from 'prosemirror-model';
5
5
  import { Selection as Selection_2 } from 'prosemirror-state';
6
6
 
7
7
  export declare interface CustomMatchers<R = unknown> {
8
8
  toEqualProseMirrorNode(expected: Node_2): R;
9
9
  }
10
10
 
11
- export declare interface KeyboardModifiers {
12
- altKey?: boolean;
13
- ctrlKey?: boolean;
14
- metaKey?: boolean;
15
- shiftKey?: boolean;
16
- }
17
-
18
11
  export declare interface Options {
12
+ autoCleanup: boolean;
19
13
  plugins: Array<Plugin_2>;
20
14
  }
21
15
 
22
16
  export declare class ProseMirrorTester {
23
17
  get doc(): Node_2;
24
- get schema(): Schema;
18
+ get html(): string;
19
+ get state(): EditorState;
20
+ get text(): string;
21
+ private destroyed;
22
+ private readonly element;
25
23
  private readonly view;
26
24
  constructor(documentRoot: Node_2, options?: Partial<Options>);
27
- insertText(text: string, modifiers?: KeyboardModifiers): void;
25
+ destroy(): void;
26
+ insertText(text: string): void;
28
27
  selectText(selection: TesterSelection): void;
28
+ private assertAlive;
29
29
  }
30
30
 
31
31
  export declare type TesterSelection = "all" | "end" | "start" | {
32
- from: number;
33
- to: number;
32
+ anchor: number;
33
+ head: number;
34
34
  } | Selection_2 | number;
35
35
 
36
36
  export { }
37
37
 
38
38
 
39
39
  declare module "vitest" {
40
- interface Assertion<T = any> extends CustomMatchers<T> {
41
- }
42
- interface AsymmetricMatchersContaining extends CustomMatchers {
40
+ interface Matchers<R extends Promise<void> | void = Promise<void> | void, T = unknown> extends CustomMatchers<R> {
43
41
  }
44
42
  }
@@ -1,45 +1,8 @@
1
- import { expect } from "vitest";
2
- import stringifyObject from "stringify-object";
1
+ import { afterEach, expect } from "vitest";
3
2
  import { AllSelection, EditorState, Selection, TextSelection } from "prosemirror-state";
4
3
  import { EditorView } from "prosemirror-view";
5
- //#region src/stringifyProseMirrorNode.ts
6
- function getMarks(marks, origContent) {
7
- let content = `'${origContent}'`;
8
- for (const mark of [...marks].reverse()) {
9
- const hasAttrs = Object.keys(mark.attrs).length > 0;
10
- const items = [content];
11
- if (hasAttrs) items.unshift(stringifyObject(mark.attrs, {
12
- indent: " ",
13
- inlineCharacterLimit: 1e3
14
- }));
15
- content = `${mark.type.name}(${items.join(", ")})`;
16
- }
17
- return content;
18
- }
19
- var renamedTypes = {
20
- hardBreak: "br",
21
- heading: "h",
22
- horizontalRule: "hr",
23
- paragraph: "p"
24
- };
25
- function stringifyProseMirrorNode(node, indentation = "") {
26
- if (node.type.name === "text") return `${indentation}${getMarks(node.marks, node.text ?? "")}`;
27
- const type = renamedTypes[node.type.name] ?? node.type.name;
28
- const content = [];
29
- const nextIndentation = `${indentation} `;
30
- if (Object.keys(node.attrs).length > 0) content.push(`${nextIndentation}${stringifyObject(node.attrs, {
31
- indent: " ",
32
- inlineCharacterLimit: 1e3
33
- })},`);
34
- node.content.forEach((item) => {
35
- content.push(`${stringifyProseMirrorNode(item, nextIndentation)},`);
36
- });
37
- if (content.length === 0) return `${indentation}${type}()`;
38
- if (content.length === 1 && node.content.firstChild?.type.name === "text") return `${indentation}${type}(${stringifyProseMirrorNode(node.content.firstChild, "")})`;
39
- return `${indentation}${type}(\n${content.join("\n")}\n${indentation})`;
40
- }
41
- //#endregion
42
- //#region src/utils/mockRangeRects.ts
4
+ import stringifyObject from "stringify-object";
5
+ //#region src/mockRangeRects.ts
43
6
  var mockRangeRects = () => {
44
7
  const zeroRect = () => new DOMRect(0, 0, 0, 0);
45
8
  const emptyRectList = () => [];
@@ -48,7 +11,7 @@ var mockRangeRects = () => {
48
11
  if (typeof rangePrototype.getClientRects !== "function") Range.prototype.getClientRects = emptyRectList;
49
12
  };
50
13
  //#endregion
51
- //#region src/utils/MutationObserverMock.ts
14
+ //#region src/MutationObserverMock.ts
52
15
  var MutationObserverMock = class MutationObserverMock {
53
16
  static activeObservers = /* @__PURE__ */ new Map();
54
17
  callback;
@@ -84,18 +47,19 @@ var MutationObserverMock = class MutationObserverMock {
84
47
  }
85
48
  };
86
49
  //#endregion
87
- //#region src/utils/selection.ts
50
+ //#region src/selection.ts
88
51
  var resolveSelection = (doc, selection) => {
89
52
  if (selection === "all") return new AllSelection(doc);
90
53
  if (typeof selection === "object" && "$anchor" in selection && "$head" in selection) return selection;
91
- if (typeof selection === "object" && "from" in selection && "to" in selection) return TextSelection.between(doc.resolve(selection.from), doc.resolve(selection.to));
54
+ if (typeof selection === "object" && "anchor" in selection && "head" in selection) return TextSelection.between(doc.resolve(selection.anchor), doc.resolve(selection.head));
55
+ if (typeof selection === "object" && "from" in selection && "to" in selection) throw new Error("selectText no longer accepts { from, to } — use { anchor, head } instead. The values map directly: { from: a, to: b } becomes { anchor: a, head: b }.");
92
56
  let pos = 0;
93
57
  if (selection === "end") pos = doc.nodeSize - 2;
94
58
  else if (selection !== "start") pos = selection;
95
59
  return TextSelection.near(doc.resolve(pos));
96
60
  };
97
61
  //#endregion
98
- //#region src/utils/dom.ts
62
+ //#region src/typing/dom.ts
99
63
  var characterDataAt = (node, offset) => {
100
64
  if (node instanceof CharacterData) return {
101
65
  offset,
@@ -108,8 +72,58 @@ var characterDataAt = (node, offset) => {
108
72
  };
109
73
  return null;
110
74
  };
75
+ function deleteText(view, direction) {
76
+ const { selection } = view.state;
77
+ const from = selection.empty && direction === -1 ? selection.from - 1 : selection.from;
78
+ const to = selection.empty && direction === 1 ? selection.to + 1 : selection.to;
79
+ const fromDOM = view.domAtPos(from, -1);
80
+ const target = characterDataAt(fromDOM.node, fromDOM.offset)?.target;
81
+ if (target === void 0) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
82
+ const nodeStart = view.posAtDOM(target, 0);
83
+ const fromOffset = from - nodeStart;
84
+ const toOffset = to - nodeStart;
85
+ if (fromOffset < 0 || toOffset > target.data.length) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
86
+ const oldValue = target.data;
87
+ target.data = target.data.slice(0, fromOffset) + target.data.slice(toOffset);
88
+ MutationObserverMock.createMutation(view.dom, [{
89
+ oldValue,
90
+ target,
91
+ type: "characterData"
92
+ }]);
93
+ }
94
+ function moveCaret(view, direction) {
95
+ const { selection } = view.state;
96
+ let pos = direction === -1 ? selection.from : selection.to;
97
+ if (selection.empty) pos += direction;
98
+ view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(pos), direction)));
99
+ }
100
+ function typeCharacter(view, character) {
101
+ const { node, offset: domOffset } = view.domAtPos(view.state.selection.from, -1);
102
+ const point = characterDataAt(node, domOffset);
103
+ if (point !== null) {
104
+ const { offset, target } = point;
105
+ const oldValue = target.data;
106
+ target.data = target.data.slice(0, offset) + character + target.data.slice(offset);
107
+ MutationObserverMock.createMutation(view.dom, [{
108
+ oldValue,
109
+ target,
110
+ type: "characterData"
111
+ }]);
112
+ return;
113
+ }
114
+ const textNode = new Text(character);
115
+ const nextSibling = node.childNodes.item(domOffset);
116
+ node.insertBefore(textNode, nextSibling);
117
+ MutationObserverMock.createMutation(view.dom, [{
118
+ addedNodes: [textNode],
119
+ nextSibling,
120
+ previousSibling: textNode.previousSibling,
121
+ target: node,
122
+ type: "childList"
123
+ }]);
124
+ }
111
125
  //#endregion
112
- //#region src/utils/keyboardInput.ts
126
+ //#region src/typing/keyboardInput.ts
113
127
  function tokenizeKeyboardInput(input) {
114
128
  const output = [];
115
129
  let currentGroupOpener = null;
@@ -140,7 +154,24 @@ function matchingBrace(opener) {
140
154
  return opener === "{" ? "}" : "]";
141
155
  }
142
156
  //#endregion
143
- //#region src/utils/keyIdentity.ts
157
+ //#region src/typing/keyChord.ts
158
+ function parseKeyChord(token) {
159
+ const parts = token.split(/-(?!$)/u);
160
+ const key = parts[parts.length - 1] === "Space" ? " " : parts[parts.length - 1];
161
+ const modifiers = {};
162
+ for (const modifier of parts.slice(0, -1)) if (/^(cmd|meta|m)$/iu.test(modifier)) modifiers.metaKey = true;
163
+ else if (/^a(lt)?$/iu.test(modifier)) modifiers.altKey = true;
164
+ else if (/^(c|ctrl|control)$/iu.test(modifier)) modifiers.ctrlKey = true;
165
+ else if (/^s(hift)?$/iu.test(modifier)) modifiers.shiftKey = true;
166
+ else if (/^mod$/iu.test(modifier)) modifiers.ctrlKey = true;
167
+ else throw new Error(`Unrecognized modifier name: ${modifier}`);
168
+ return {
169
+ key,
170
+ modifiers
171
+ };
172
+ }
173
+ //#endregion
174
+ //#region src/typing/keyIdentity.ts
144
175
  var namedKeys = new Map(Object.entries({
145
176
  Alt: 18,
146
177
  ArrowDown: 40,
@@ -457,7 +488,7 @@ function keyIdentity(key) {
457
488
  };
458
489
  }
459
490
  //#endregion
460
- //#region src/utils/typing.ts
491
+ //#region src/typing/typing.ts
461
492
  var ignoredKeys = /* @__PURE__ */ new Set([
462
493
  "Alt",
463
494
  "CapsLock",
@@ -466,15 +497,14 @@ var ignoredKeys = /* @__PURE__ */ new Set([
466
497
  "Shift",
467
498
  "Tab"
468
499
  ]);
469
- var backward = -1;
470
- var forward = 1;
471
500
  var hasModifiers = (modifiers) => modifiers !== void 0 && Object.values(modifiers).includes(true);
472
501
  var suppressesCharacter = (modifiers) => modifiers?.altKey === true || modifiers?.ctrlKey === true || modifiers?.metaKey === true;
473
502
  var isLetter = (key) => /^[a-z]$/iu.test(key);
474
- function insertText(view, text, modifiers) {
475
- for (const key of tokenizeKeyboardInput(text)) {
476
- const character = isLetter(key) && modifiers?.shiftKey === true ? key.toUpperCase() : key;
477
- const shiftKey = isLetter(character) ? character === character.toUpperCase() : modifiers?.shiftKey ?? false;
503
+ function insertText(view, text) {
504
+ for (const token of tokenizeKeyboardInput(text)) {
505
+ const { key, modifiers } = parseKeyChord(token);
506
+ const character = isLetter(key) && modifiers.shiftKey === true ? key.toUpperCase() : key;
507
+ const shiftKey = isLetter(character) ? character === character.toUpperCase() : modifiers.shiftKey ?? false;
478
508
  const identity = keyIdentity(character);
479
509
  const eventInit = {
480
510
  bubbles: true,
@@ -506,37 +536,18 @@ function insertText(view, text, modifiers) {
506
536
  view.dispatchEvent(new KeyboardEvent("keyup", eventInit));
507
537
  }
508
538
  }
509
- function deleteText(view, direction) {
510
- const { selection } = view.state;
511
- const from = selection.empty && direction === backward ? selection.from - 1 : selection.from;
512
- const to = selection.empty && direction === forward ? selection.to + 1 : selection.to;
513
- const fromDOM = view.domAtPos(from, backward);
514
- const target = characterDataAt(fromDOM.node, fromDOM.offset)?.target;
515
- if (target === void 0) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
516
- const nodeStart = view.posAtDOM(target, 0);
517
- const fromOffset = from - nodeStart;
518
- const toOffset = to - nodeStart;
519
- if (fromOffset < 0 || toOffset > target.data.length) throw new Error("Cannot simulate deleting a range that is not inside a single text node");
520
- const oldValue = target.data;
521
- target.data = target.data.slice(0, fromOffset) + target.data.slice(toOffset);
522
- MutationObserverMock.createMutation(view.dom, [{
523
- oldValue,
524
- target,
525
- type: "characterData"
526
- }]);
527
- }
528
539
  function keyAction(key, modifiers) {
529
540
  if (ignoredKeys.has(key)) return { type: "ignore" };
530
541
  if (key === "Backspace") return {
531
- direction: backward,
542
+ direction: -1,
532
543
  type: "delete"
533
544
  };
534
545
  if (key === "Delete") return {
535
- direction: forward,
546
+ direction: 1,
536
547
  type: "delete"
537
548
  };
538
549
  if ((key === "ArrowLeft" || key === "ArrowRight") && !hasModifiers(modifiers)) return {
539
- direction: key === "ArrowLeft" ? backward : forward,
550
+ direction: key === "ArrowLeft" ? -1 : 1,
540
551
  type: "moveCaret"
541
552
  };
542
553
  if (key.length === 1) {
@@ -548,74 +559,129 @@ function keyAction(key, modifiers) {
548
559
  }
549
560
  throw new Error(`Cannot simulate the "${key}" key`);
550
561
  }
551
- function moveCaret(view, direction) {
552
- const { selection } = view.state;
553
- let pos = direction === backward ? selection.from : selection.to;
554
- if (selection.empty) pos += direction;
555
- view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(pos), direction)));
556
- }
557
- function typeCharacter(view, character) {
558
- const { node, offset: domOffset } = view.domAtPos(view.state.selection.from, backward);
559
- const point = characterDataAt(node, domOffset);
560
- if (point !== null) {
561
- const { offset, target } = point;
562
- const oldValue = target.data;
563
- target.data = target.data.slice(0, offset) + character + target.data.slice(offset);
564
- MutationObserverMock.createMutation(view.dom, [{
565
- oldValue,
566
- target,
567
- type: "characterData"
568
- }]);
569
- return;
570
- }
571
- const textNode = new Text(character);
572
- const nextSibling = node.childNodes.item(domOffset);
573
- node.insertBefore(textNode, nextSibling);
574
- MutationObserverMock.createMutation(view.dom, [{
575
- addedNodes: [textNode],
576
- nextSibling,
577
- previousSibling: textNode.previousSibling,
578
- target: node,
579
- type: "childList"
580
- }]);
581
- }
582
562
  //#endregion
583
563
  //#region src/ProseMirrorTester.ts
564
+ var originalMutationObserver = global.MutationObserver;
565
+ var liveTesters = /* @__PURE__ */ new Set();
566
+ var autoCleanupTesters = /* @__PURE__ */ new Set();
567
+ var cleanupTesters = () => {
568
+ for (const tester of [...autoCleanupTesters]) tester.destroy();
569
+ };
584
570
  var ProseMirrorTester = class {
585
571
  get doc() {
572
+ this.assertAlive();
586
573
  return this.view.state.doc;
587
574
  }
588
- get schema() {
589
- return this.view.state.schema;
575
+ get html() {
576
+ this.assertAlive();
577
+ return this.view.dom.innerHTML;
578
+ }
579
+ get state() {
580
+ this.assertAlive();
581
+ return this.view.state;
582
+ }
583
+ get text() {
584
+ this.assertAlive();
585
+ return this.view.dom.textContent;
590
586
  }
587
+ destroyed = false;
588
+ element;
591
589
  view;
592
590
  constructor(documentRoot, options = {}) {
593
591
  if (typeof document === "undefined") throw new Error("TODO");
594
- const element = document.createElement("div");
595
- document.body.append(element);
592
+ this.element = document.createElement("div");
593
+ document.body.append(this.element);
596
594
  const state = EditorState.create({
597
595
  doc: documentRoot,
598
596
  plugins: options.plugins ?? []
599
597
  });
600
598
  global.MutationObserver = MutationObserverMock;
601
599
  mockRangeRects();
602
- this.view = new EditorView(element, { state });
600
+ this.view = new EditorView(this.element, { state });
601
+ liveTesters.add(this);
602
+ if (options.autoCleanup ?? true) autoCleanupTesters.add(this);
603
603
  }
604
- insertText(text, modifiers) {
605
- insertText(this.view, text, modifiers);
604
+ destroy() {
605
+ if (this.destroyed) return;
606
+ this.destroyed = true;
607
+ this.view.destroy();
608
+ this.element.remove();
609
+ document.getSelection()?.removeAllRanges();
610
+ autoCleanupTesters.delete(this);
611
+ liveTesters.delete(this);
612
+ if (liveTesters.size === 0) global.MutationObserver = originalMutationObserver;
613
+ }
614
+ insertText(text) {
615
+ this.assertAlive();
616
+ insertText(this.view, text);
606
617
  }
607
618
  selectText(selection) {
619
+ this.assertAlive();
608
620
  this.view.dispatch(this.view.state.tr.setSelection(resolveSelection(this.doc, selection)));
609
621
  }
622
+ assertAlive() {
623
+ if (this.destroyed) throw new Error("This ProseMirrorTester has been destroyed. Testers are destroyed automatically after each test; pass { autoCleanup: false } to keep one alive (e.g. across a beforeAll).");
624
+ }
610
625
  };
611
626
  //#endregion
627
+ //#region src/stringifyProseMirrorNode.ts
628
+ function stringifyProseMirrorNode(node, indentation = "") {
629
+ if (node.type.spec.toDebugString !== void 0) return `${indentation}${wrapMarks(node.marks, node.type.spec.toDebugString(node))}`;
630
+ if (node.type.name === "text") return `${indentation}${wrapMarks(node.marks, escapeText(node.textContent))}`;
631
+ const type = node.type.name;
632
+ const content = [];
633
+ const nextIndentation = `${indentation} `;
634
+ if (Object.keys(node.attrs).length > 0) content.push(`${nextIndentation}${stringifyObject(node.attrs, {
635
+ indent: " ",
636
+ inlineCharacterLimit: 1e3
637
+ })},`);
638
+ node.content.forEach((item) => {
639
+ content.push(`${stringifyProseMirrorNode(item, nextIndentation)},`);
640
+ });
641
+ let body;
642
+ if (content.length === 0) body = `${type}()`;
643
+ else if (content.length === 1 && node.content.firstChild?.type.name === "text") body = `${type}(${stringifyProseMirrorNode(node.content.firstChild, "")})`;
644
+ else body = `${type}(\n${content.join("\n")}\n${indentation})`;
645
+ return `${indentation}${wrapMarks(node.marks, body)}`;
646
+ }
647
+ function escapeText(text) {
648
+ return `'${text.replace(/[\p{Cc}'\\]/gu, (char) => {
649
+ switch (char) {
650
+ case "\f": return "\\f";
651
+ case "\n": return "\\n";
652
+ case "\r": return "\\r";
653
+ case " ": return "\\t";
654
+ case "\b": return "\\b";
655
+ case "'": return "\\'";
656
+ case "\\": return "\\\\";
657
+ default: return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
658
+ }
659
+ })}'`;
660
+ }
661
+ function wrapMarks(marks, origContent) {
662
+ let content = origContent;
663
+ for (const mark of [...marks].reverse()) {
664
+ const hasAttrs = Object.keys(mark.attrs).length > 0;
665
+ const items = [content];
666
+ if (hasAttrs) items.unshift(stringifyObject(mark.attrs, {
667
+ indent: " ",
668
+ inlineCharacterLimit: 1e3
669
+ }));
670
+ content = `${mark.type.name}(${items.join(", ")})`;
671
+ }
672
+ return content;
673
+ }
674
+ //#endregion
612
675
  //#region src/index.ts
676
+ afterEach(cleanupTesters);
613
677
  expect.extend({ toEqualProseMirrorNode(received, expected) {
614
678
  const receivedDoc = `\n${stringifyProseMirrorNode(received)}\n`;
615
679
  const expectedDoc = `\n${stringifyProseMirrorNode(expected)}\n`;
616
- const pass = this.equals(receivedDoc, expectedDoc);
680
+ const sameSchema = received.type.schema === expected.type.schema;
681
+ const pass = sameSchema && this.equals(receivedDoc, expectedDoc);
617
682
  return {
618
683
  message: pass ? () => `${this.utils.matcherHint(".not.toEqualProsemirrorNode")}\n\nExpected value of document to not equal:\n ${this.utils.printExpected(expectedDoc)}\nActual:\n ${this.utils.printReceived(receivedDoc)}` : () => {
684
+ if (!sameSchema && this.equals(receivedDoc, expectedDoc)) return `${this.utils.matcherHint(".toEqualProsemirrorNode")}\n\nThe documents stringify identically but come from different schemas:\n${this.utils.printReceived(receivedDoc)}`;
619
685
  const diffString = this.utils.diff(expectedDoc, receivedDoc);
620
686
  return `${this.utils.matcherHint(".toEqualProsemirrorNode")}\n\nExpected value of document to equal:\n${this.utils.printExpected(expectedDoc)}\nActual:\n${this.utils.printReceived(receivedDoc)}${diffString === void 0 ? "" : `\n\nDifference:\n\n${diffString}`}`;
621
687
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-prosemirror",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A plugin for Vitest that enables you to write tests using the ProseMirror editor",
5
5
  "keywords": [
6
6
  "vitest",
@@ -61,7 +61,7 @@
61
61
  "@microsoft/api-extractor": "^7.58.7",
62
62
  "@types/node": "^26.0.0",
63
63
  "@types/stringify-object": "^4.0.5",
64
- "@vitest/coverage-v8": "^4.0.4",
64
+ "@vitest/coverage-v8": "^5.0.0",
65
65
  "@vitest/eslint-plugin": "^1.3.26",
66
66
  "es-check": "^9.6.4",
67
67
  "eslint": "^10.0.2",
@@ -82,11 +82,11 @@
82
82
  "typescript-eslint": "^8.58.0",
83
83
  "unplugin-dts": "^1.0.1",
84
84
  "vite": "^8.0.0",
85
- "vitest": "^4.0.4",
85
+ "vitest": "^5.0.0",
86
86
  "w3c-keyname": "^2.2.8"
87
87
  },
88
88
  "peerDependencies": {
89
- "vitest": "^2.1.9 || ^3.0.7 || ^4.0.4"
89
+ "vitest": "^2.1.9 || ^3.0.7 || ^4.0.4 || ^5.0.0"
90
90
  },
91
91
  "peerDependenciesMeta": {
92
92
  "vitest": {