All files / collaborative-slate/src/sync/__tests__/tools fuzzer.ts

93.68% Statements 89/95
66.66% Branches 18/27
96% Functions 24/25
96.38% Lines 80/83

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 1792x                 2x       20x   2x 20x 20x 20x 7709x 7709x 7709x 7709x 7709x           5369x 5369x       2x 1953x 1953x 4297x 4297x 5369x 5369x 5369x 2344x     1953x 1953x     2x           2x 6441x     2x 156x 156x 156x 452x   156x     2x 2x   2x           10x 10x 10x 10x 268x   10x             20x     20x 20x     20x 20x         1917x       2953x       1953x 5369x 1953x 1953x 1953x   1953x         1953x 1953x       1268x 1268x 1268x 1268x       1268x 1268x 1268x       1268x 1268x   156x   156x       197x       266x     220x   220x     221x   221x     208x   208x 208x   697x       1268x      
import {createEditor, Editor, Element, Transforms} from 'slate';
import type {
  SlateDocument,
  SlateDescendantNode,
  SlateElementNode,
  SlateTextNode,
  SlateRange,
  SlatePoint,
} from '../../../types';
import {type SlateTrace, SlateTraceRecorder} from './traces';
 
export type Rng = () => number;
 
export const createEmptyDocument = (): SlateDocument => [{type: 'paragraph', children: [{text: ''}]}];
 
export const createRng = (seed: number): Rng => {
  let m_w = seed;
  let m_z = 987654321 ^ seed;
  return () => {
    m_z = (36969 * (m_z & 65535) + (m_z >> 16)) | 0;
    m_w = (18000 * (m_w & 65535) + (m_w >> 16)) | 0;
    let result = ((m_z << 16) + (m_w & 65535)) >>> 0;
    result /= 4294967296;
    return result;
  };
};
 
class FlatNode {
  constructor(
    public readonly path: number[],
    public readonly node: SlateDescendantNode,
  ) {}
}
 
export const flatten = (doc: SlateDocument): FlatNode[] => {
  const nodes: FlatNode[] = [];
  const walk = (elements: SlateDescendantNode[], path: number[] = []) => {
    const length = elements.length;
    for (let i = 0; i < length; i++) {
      const node = elements[i];
      nodes.push(new FlatNode([...path, i], node));
      if (!('text' in node) && (node as SlateElementNode).children)
        walk((node as SlateElementNode).children, [...path, i]);
    }
  };
  walk(doc);
  return nodes;
};
 
export const pickRandomNode = (rng: Rng, doc: SlateDocument): FlatNode | undefined => {
  const nodes = flatten(doc);
  return nodes[Math.floor(rng() * nodes.length)];
};
 
/** Pick random integer between min (inclusive) and max (inclusive). */
const randomInt = (rng: Rng, min: number, max: number): number => {
  return Math.floor(rng() * (max - min + 1)) + min;
};
 
const randomText = (rng: Rng, length: number): string => {
  const chars = 'abcdefghijklmnopqrstuvwxyz ';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += chars.charAt(randomInt(rng, 0, chars.length - 1));
  }
  return result;
};
 
const blockElementTypes: string[] = ['paragraph', 'h1', 'list-item', 'bulleted-list', 'numbered-list'];
const inlineFormattingMarks: string[] = ['bold', 'italic', 'underline', 'code'];
 
export class SlateFuzzer {
  public static genTrace(
    seed: number = Math.floor(Math.random() * 1000000),
    minOps: number = 10,
    maxOps: number = 50,
  ): SlateTrace {
    const fuzzer = new SlateFuzzer(seed);
    const recorder = new SlateTraceRecorder(fuzzer.editor);
    const totalOps = randomInt(fuzzer.rng, minOps, maxOps);
    for (let i = 0; i < totalOps; i++) {
      fuzzer.applyRandomHighLevelOperation();
    }
    return recorder.getTrace();
  }
 
  public readonly rng: Rng;
  public readonly editor: Editor;
 
  constructor(
    public readonly seed: number = 123456789,
    editor?: Editor,
  ) {
    this.rng = createRng(seed);
    Iif (editor) {
      this.editor = editor;
    } else {
      this.editor = createEditor();
      (this.editor.children as unknown) = createEmptyDocument();
    }
  }
 
  private pick<T>(selection: T[]): T {
    return selection[randomInt(this.rng, 0, selection.length - 1)];
  }
 
  public getDocument(): SlateDocument {
    return this.editor.children as SlateDocument;
  }
 
  public generateRandomPoint(): SlatePoint | undefined {
    const doc = this.getDocument();
    const flatNodes = flatten(doc).filter((n) => 'text' in n.node);
    const index = randomInt(this.rng, 0, flatNodes.length - 1);
    const node = flatNodes[index] as FlatNode | undefined;
    Iif (!node) return;
    const maxOffset =
      'text' in node.node
        ? (node.node as SlateTextNode).text.length
        : 'children' in node.node
          ? (node.node as SlateElementNode).children.length
          : 0;
    const offset = randomInt(this.rng, 0, maxOffset);
    return {path: node.path, offset};
  }
 
  public generateRandomRange(): SlateRange | undefined {
    const anchor = this.generateRandomPoint();
    Iif (!anchor) return;
    const focus = this.rng() > 0.5 ? (this.generateRandomPoint() ?? anchor) : anchor;
    return {anchor, focus};
  }
 
  public setRandomSelection(): void {
    const range = this.generateRandomRange();
    Iif (!range) return;
    Transforms.select(this.editor, range);
  }
 
  public applyRandomHighLevelOperation() {
    this.setRandomSelection();
    const op = this.pick([
      () => {
        const text = randomText(this.rng, randomInt(this.rng, 1, 5));
        // console.log('insertText', text, this.editor.selection);
        this.editor.insertText(text);
      },
      () => {
        // console.log('delete', this.editor.selection);
        this.editor.delete();
      },
      () => {
        // console.log('splitNodes');
        this.editor.splitNodes();
      },
      () => {
        const mark = this.pick(inlineFormattingMarks);
        // console.log('addMark', mark);
        this.editor.addMark(mark, true);
      },
      () => {
        const mark = this.pick(inlineFormattingMarks);
        // console.log('removeMark', mark);
        this.editor.removeMark(mark);
      },
      () => {
        const type = this.pick(blockElementTypes);
        // console.log('setNodes - block type', type);
        const editor = this.editor;
        Transforms.setNodes(this.editor, {type} as any, {
          // Ensure we only target block-level elements, not text nodes
          match: (n) => Element.isElement(n) && Editor.isBlock(editor, n),
        });
      },
    ]);
    op();
  }
}