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 7818x 7818x 7818x 7818x 7818x           5750x 5750x       2x 1981x 1981x 4487x 4487x 5750x 5750x 5750x 2506x     1981x 1981x     2x           2x 6521x     2x 153x 153x 153x 422x   153x     2x 2x   2x           10x 10x 10x 10x 297x   10x             20x     20x 20x     20x 20x         1974x       2981x       1981x 5750x 1981x 1981x 1981x   1981x         1981x 1981x       1297x 1297x 1297x 1297x       1297x 1297x 1297x       1297x 1297x   153x   153x       194x       273x     236x   236x     210x   210x     231x   231x 231x   779x       1297x      
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();
  }
}