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 7930x 7930x 7930x 7930x 7930x           5693x 5693x       2x 2006x 2006x 4479x 4479x 5693x 5693x 5693x 2473x     2006x 2006x     2x           2x 6630x     2x 162x 162x 162x 467x   162x     2x 2x   2x           10x 10x 10x 10x 300x   10x             20x     20x 20x     20x 20x         1979x       3006x       2006x 5693x 2006x 2006x 2006x   2006x         2006x 2006x       1300x 1300x 1300x 1300x       1300x 1300x 1300x       1300x 1300x   162x   162x       184x       275x     232x   232x     214x   214x     233x   233x 233x   784x       1300x      
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();
  }
}