Skip to content

Commit 1f3a009

Browse files
authored
Merge pull request #664 from streamich/quill-fuzzer-2
Quill Delta extension fuzz tester
2 parents 2290392 + 1512870 commit 1f3a009

File tree

14 files changed

+614
-2
lines changed

14 files changed

+614
-2
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@
150150
"tslib": "^2.6.2",
151151
"tslint": "^6.1.3",
152152
"tslint-config-common": "^1.6.2",
153-
"typescript": "^5.4.5"
153+
"typescript": "^5.4.5",
154+
"yjs": "^13.6.18"
154155
},
155156
"jest": {
156157
"moduleFileExtensions": [
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import Delta from 'quill-delta';
2+
import {randomU32} from 'hyperdyperid/lib/randomU32';
3+
import {Fuzzer} from '@jsonjoy.com/util/lib/Fuzzer';
4+
import {isEmpty} from '@jsonjoy.com/util/lib/isEmpty';
5+
import {QuillDeltaAttributes, QuillDeltaOp, QuillDeltaOpInsert, QuillDeltaOpRetain, QuillTrace} from '../types';
6+
import {RandomJson} from '../../../json-random';
7+
import {removeErasures} from '../util';
8+
9+
export interface QuillDeltaFuzzerOptions {
10+
maxOperationsPerPatch: number;
11+
}
12+
13+
export class QuillDeltaFuzzer {
14+
public options: QuillDeltaFuzzerOptions;
15+
public delta: Delta = new Delta([]);
16+
public patch: QuillDeltaOp[] = [];
17+
public transactions: QuillDeltaOp[][] = [];
18+
19+
constructor(options: Partial<QuillDeltaFuzzerOptions> = {}) {
20+
this.options = {
21+
maxOperationsPerPatch: 3,
22+
...options,
23+
};
24+
}
25+
26+
public trace(): QuillTrace {
27+
return {
28+
contents: {ops: this.delta.ops as QuillDeltaOp[]},
29+
transactions: this.transactions,
30+
};
31+
}
32+
33+
public applyPatch(): Delta {
34+
this.transactions.push(this.patch);
35+
this.delta = this.delta.compose(new Delta(this.patch));
36+
return this.delta;
37+
}
38+
39+
public createPatch(): QuillDeltaOp[] {
40+
this.patch = [];
41+
let pos = 0;
42+
const length = this.delta.length();
43+
const opCount = randomU32(1, this.options.maxOperationsPerPatch);
44+
for (let i = 0; i < opCount; i++) {
45+
const remaining = length - pos;
46+
if (!remaining) {
47+
const op = Fuzzer.pick([
48+
() => {
49+
const [offset, patch] = this.createInsertTextOp(this.delta, pos);
50+
this.patch.push(...patch);
51+
pos += offset;
52+
},
53+
() => {
54+
const [offset, patch] = this.createInsertEmbedOp(this.delta, pos);
55+
this.patch.push(...patch);
56+
pos += offset;
57+
},
58+
]);
59+
op();
60+
break;
61+
}
62+
const op = Fuzzer.pick([
63+
() => {
64+
const [offset, patch] = this.createInsertTextOp(this.delta, pos);
65+
this.patch.push(...patch);
66+
pos += offset;
67+
},
68+
() => {
69+
const [offset, patch] = this.createInsertEmbedOp(this.delta, pos);
70+
this.patch.push(...patch);
71+
pos += offset;
72+
},
73+
() => {
74+
const [offset, patch] = this.createDeleteOp(length, pos);
75+
this.patch.push(...patch);
76+
pos += offset;
77+
},
78+
() => {
79+
const [offset, patch] = this.createAnnotateOp(length, pos);
80+
this.patch.push(...patch);
81+
pos += offset;
82+
},
83+
]);
84+
op();
85+
}
86+
return this.patch;
87+
}
88+
89+
public createInsertOp(delta: Delta, pos: number): [offset: number, patch: QuillDeltaOp[]] {
90+
const [offset, patch] = this.createInsertTextOp(delta, pos);
91+
return [offset, patch];
92+
}
93+
94+
public createInsertTextOp(delta: Delta, pos: number): [offset: number, patch: QuillDeltaOp[]] {
95+
const length = delta.length();
96+
const remaining = length - pos;
97+
const offset = !remaining ? 0 : randomU32(0, remaining);
98+
const text = RandomJson.genString(randomU32(1, 5));
99+
const patch: QuillDeltaOp[] = [];
100+
if (offset > 0) {
101+
patch.push({retain: offset});
102+
}
103+
const insertOp: QuillDeltaOpInsert = {insert: text};
104+
if (randomU32(0, 1)) {
105+
const attributes = removeErasures(this.createAttributes());
106+
if (attributes && !isEmpty(attributes)) {
107+
insertOp.attributes = attributes;
108+
}
109+
}
110+
patch.push(insertOp);
111+
return [offset, patch];
112+
}
113+
114+
public createInsertEmbedOp(delta: Delta, pos: number): [offset: number, patch: QuillDeltaOp[]] {
115+
const length = delta.length();
116+
const remaining = length - pos;
117+
const offset = !remaining ? 0 : randomU32(0, remaining);
118+
const insert = {link: RandomJson.genString(randomU32(1, 5))};
119+
const patch: QuillDeltaOp[] = [];
120+
if (offset > 0) {
121+
patch.push({retain: offset});
122+
}
123+
const insertOp: QuillDeltaOpInsert = {insert};
124+
if (randomU32(0, 1)) {
125+
const attributes = removeErasures(this.createAttributes());
126+
if (attributes && !isEmpty(attributes)) {
127+
insertOp.attributes = attributes;
128+
}
129+
}
130+
return [offset, patch];
131+
}
132+
133+
public createDeleteOp(length: number, pos: number): [offset: number, patch: QuillDeltaOp[]] {
134+
const remaining = length - pos;
135+
if (remaining <= 0) return [pos, []];
136+
const deleteLength = randomU32(1, remaining);
137+
const patch: QuillDeltaOp[] = [{delete: deleteLength}];
138+
return [deleteLength, patch];
139+
}
140+
141+
public createAnnotateOp(length: number, pos: number): [offset: number, patch: QuillDeltaOp[]] {
142+
const remaining = length - pos;
143+
if (remaining <= 0) return [pos, []];
144+
const offset = remaining < 2 ? 0 : randomU32(0, remaining - 1);
145+
const annotationLength = randomU32(1, remaining - offset);
146+
const patch: QuillDeltaOp[] = [];
147+
if (offset > 0) {
148+
patch.push({retain: offset});
149+
}
150+
const retainOp: QuillDeltaOpRetain = {retain: annotationLength};
151+
const attributes = this.createAttributes();
152+
if (attributes && !isEmpty(attributes)) {
153+
retainOp.attributes = attributes;
154+
}
155+
patch.push(retainOp);
156+
return [offset + annotationLength, patch];
157+
}
158+
159+
public createAttributes(): QuillDeltaAttributes {
160+
const length = randomU32(1, 3);
161+
const attrKeys = ['bold', 'italic', 'color'];
162+
const attributes: QuillDeltaAttributes = {};
163+
for (let i = 0; i < length; i++) {
164+
const attrKey = Fuzzer.pick(attrKeys);
165+
switch (attrKey) {
166+
case 'bold': {
167+
attributes.bold = Fuzzer.pick([true, null]);
168+
break;
169+
}
170+
case 'italic': {
171+
attributes.italic = Fuzzer.pick([true, null]);
172+
break;
173+
}
174+
case 'color': {
175+
attributes.color = Fuzzer.pick(['red', 'blue', null]);
176+
break;
177+
}
178+
}
179+
}
180+
return attributes;
181+
}
182+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {QuillDeltaFuzzer} from './QuillDeltaFuzzer';
2+
import {Model} from '../../../json-crdt/model';
3+
import {quill as QuillDeltaExt} from '..';
4+
5+
for (let j = 0; j < 250; j++) {
6+
test('fuzz - ' + j, () => {
7+
const model = Model.create();
8+
model.ext.register(QuillDeltaExt);
9+
model.api.root(QuillDeltaExt.new(''));
10+
const quill = model.api.in().asExt(QuillDeltaExt);
11+
const fuzzer = new QuillDeltaFuzzer({
12+
maxOperationsPerPatch: 3,
13+
});
14+
for (let i = 0; i < 50; i++) {
15+
const patch = fuzzer.createPatch();
16+
quill.apply(patch);
17+
fuzzer.applyPatch();
18+
}
19+
// console.log(quill.view());
20+
// console.log(fuzzer.trace().contents.ops);
21+
// console.log(fuzzer.trace().transactions);
22+
try {
23+
expect(quill.view()).toEqual(fuzzer.trace().contents.ops);
24+
} catch (error) {
25+
// tslint:disable-next-line:no-console
26+
console.log(JSON.stringify(fuzzer.trace()));
27+
throw error;
28+
}
29+
// console.log(fuzzer.trace(), fuzzer.trace().contents.ops);
30+
// console.log(JSON.stringify(fuzzer.trace().transactions, null, 2));
31+
});
32+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {QuillDeltaFuzzer} from './QuillDeltaFuzzer';
2+
import {Doc as YDoc} from 'yjs';
3+
import {QuillDeltaOp, QuillDeltaOpDelete} from '../types';
4+
import {deepEqual} from '../../../json-equal/deepEqual';
5+
import {clone} from '../../../json-clone';
6+
7+
const normalizeDelta = (delta: QuillDeltaOp[]): QuillDeltaOp[] => {
8+
const length = delta.length;
9+
if (!length) return [];
10+
const normalized: QuillDeltaOp[] = [delta[0]];
11+
const toDelete: number[] = [];
12+
let last = delta[0];
13+
for (let i = 1; i < length; i++) {
14+
const curr = delta[i];
15+
if (typeof (last as any).delete === 'number' && typeof (curr as any).delete === 'number') {
16+
(last as QuillDeltaOpDelete).delete += (curr as QuillDeltaOpDelete).delete;
17+
toDelete.push(i);
18+
} else if (
19+
typeof (last as any).retain === 'number' &&
20+
typeof (curr as any).retain === 'number' &&
21+
deepEqual((<any>last).attributes, (<any>curr).attributes)
22+
) {
23+
(last as any).retain += (curr as any).retain;
24+
toDelete.push(i);
25+
} else if (
26+
typeof (last as any).insert === 'string' &&
27+
typeof (curr as any).insert === 'string' &&
28+
deepEqual((<any>last).attributes, (<any>curr).attributes)
29+
) {
30+
(last as any).insert += (curr as any).insert;
31+
toDelete.push(i);
32+
} else {
33+
normalized.push(curr);
34+
last = curr;
35+
}
36+
}
37+
for (const index of toDelete.reverse()) delta.splice(index, 1);
38+
return normalized;
39+
};
40+
41+
for (let j = 0; j < 100; j++) {
42+
test('fuzz - ' + j, () => {
43+
const doc = new YDoc();
44+
const text = doc.getText('quill');
45+
const fuzzer = new QuillDeltaFuzzer({
46+
maxOperationsPerPatch: 3,
47+
});
48+
for (let i = 0; i < 25; i++) {
49+
const patch = fuzzer.createPatch();
50+
fuzzer.applyPatch();
51+
text.applyDelta(clone(patch));
52+
}
53+
const delta = text.toDelta();
54+
const deltaNormalized = normalizeDelta(text.toDelta());
55+
try {
56+
expect(deltaNormalized).toEqual(fuzzer.trace().contents.ops);
57+
} catch (error) {
58+
// tslint:disable-next-line:no-console
59+
console.log(JSON.stringify(fuzzer.trace()));
60+
// tslint:disable-next-line:no-console
61+
console.log(delta);
62+
// tslint:disable-next-line:no-console
63+
console.log(deltaNormalized);
64+
// tslint:disable-next-line:no-console
65+
console.log(fuzzer.trace().contents.ops);
66+
throw error;
67+
}
68+
});
69+
}

src/json-crdt-extensions/quill-delta/__tests__/traces.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import {splitUnSplitQuillTrace} from './traces/split-unsplit';
1414
import {blockHandlingQuillTrace} from './traces/blocks';
1515
import {insertDeleteImageQuillTrace} from './traces/insert-delete-image';
1616
import {annotateAnnotationsQuillTrace} from './traces/annotate-annotations';
17+
import {fuzz1QuillTrace} from './traces/fuzz-1';
18+
import {fuzz2QuillTrace} from './traces/fuzz-2';
19+
import {fuzz3QuillTrace} from './traces/fuzz-3';
20+
import {fuzz4QuillTrace} from './traces/fuzz-4';
21+
import {fuzz5QuillTrace} from './traces/fuzz-5';
22+
import {fuzz6QuillTrace} from './traces/fuzz-6';
23+
import {fuzz7QuillTrace} from './traces/fuzz-7';
1724

1825
const assertTrace = (trace: QuillTrace, api: QuillDeltaApi) => {
1926
let delta = new Delta([]);
@@ -53,6 +60,13 @@ const traces: Trace[] = [
5360
['block-handling', blockHandlingQuillTrace],
5461
['insert-delete-image', insertDeleteImageQuillTrace],
5562
['annotate-annotations', annotateAnnotationsQuillTrace],
63+
['fuzz-1', fuzz1QuillTrace],
64+
['fuzz-2', fuzz2QuillTrace],
65+
['fuzz-3', fuzz3QuillTrace],
66+
['fuzz-4', fuzz4QuillTrace],
67+
['fuzz-5', fuzz5QuillTrace],
68+
['fuzz-6', fuzz6QuillTrace],
69+
['fuzz-7', fuzz7QuillTrace],
5670
];
5771

5872
for (const [name, trace] of traces) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {QuillTrace} from '../../types';
2+
3+
export const fuzz1QuillTrace: QuillTrace = {
4+
contents: {
5+
ops: [
6+
{
7+
insert: {
8+
link: 'x$1||',
9+
},
10+
},
11+
],
12+
},
13+
transactions: [
14+
[
15+
{
16+
insert: 'Y.?',
17+
},
18+
],
19+
[
20+
{
21+
retain: 2,
22+
},
23+
{
24+
insert: 'Km',
25+
},
26+
],
27+
[
28+
{
29+
insert: 'S',
30+
},
31+
{
32+
delete: 2,
33+
},
34+
{
35+
retain: 3,
36+
},
37+
{
38+
insert: {
39+
link: 'x$1||',
40+
},
41+
},
42+
],
43+
[
44+
{
45+
retain: 2,
46+
},
47+
{
48+
insert: {
49+
link: '5/6a',
50+
},
51+
},
52+
{
53+
delete: 2,
54+
},
55+
],
56+
[
57+
{
58+
insert: {
59+
link: 'г西',
60+
},
61+
},
62+
],
63+
[
64+
{
65+
delete: 3,
66+
},
67+
{
68+
delete: 1,
69+
},
70+
],
71+
],
72+
};

0 commit comments

Comments
 (0)