Skip to content

Commit

Permalink
Add fuzzing tests
Browse files Browse the repository at this point in the history
  • Loading branch information
luin committed Jun 23, 2023
1 parent 1c2521e commit d4c1883
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 35 deletions.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ formats/*.js
modules/*.js
themes/*.js
ui/*.js
test/random.js

test/**/*.js
!test/helpers/**/*.js
!test/unit/**/*.js
!test/unit.js

core.js
quill.js
Expand Down
1 change: 1 addition & 0 deletions _develop/karma.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module.exports = config => {
},
{ pattern: 'dist/quill.snow.css', nocache: true },
{ pattern: 'dist/unit.js', nocache: true },
{ pattern: 'dist/fuzzing.js', nocache: true },
{ pattern: 'dist/*.map', included: false, served: true, nocache: true },
{ pattern: 'assets/favicon.png', included: false, served: true },
],
Expand Down
1 change: 1 addition & 0 deletions _develop/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const baseConfig = {
'quill.bubble': './assets/bubble.styl',
'quill.snow': './assets/snow.styl',
'unit.js': './test/unit.js',
'fuzzing.js': './test/fuzzing.ts',
},
output: {
filename: '[name]',
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,11 @@
"website:build": "npm run build -w website",
"website:serve": "npm run serve -w website -- --port $npm_package_config_ports_gatsby",
"website:develop": "npm run develop -w website -- --port $npm_package_config_ports_gatsby",
"test": "npm run test:unit; npm run test:random",
"test:all": "npm run test:unit; npm run test:functional; npm run test:random",
"test": "npm run test:unit",
"test:all": "npm run test:unit; npm run test:functional",
"test:e2e": "npx playwright test",
"test:unit": "npm run build; karma start _develop/karma.config.js",
"test:unit:ci": "npm run build; karma start _develop/karma.config.js --reporters dots,saucelabs",
"test:random": "ts-node --preferTsExts -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/jasmine test/random.ts",
"test:coverage": "webpack --env.coverage --config _develop/webpack.config.js; karma start _develop/karma.config.js --reporters coverage"
},
"overrides": {
Expand Down
2 changes: 2 additions & 0 deletions test/fuzzing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './fuzzing/applyDelta.test';
import './fuzzing/tableEmbed.test';
224 changes: 224 additions & 0 deletions test/fuzzing/applyDelta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import Delta, { AttributeMap, Op } from 'quill-delta';
import { choose, randomInt } from './utils';
import { AlignClass } from '../../formats/align';
import { FontClass } from '../../formats/font';
import { SizeClass } from '../../formats/size';
import Quill from '../../quill';

type AttributeDef = { name: string; values: (number | string | boolean)[] };
const BLOCK_EMBED_NAME = 'video';
const INLINE_EMBED_NAME = 'image';

const attributeDefs: {
text: AttributeDef[];
newline: AttributeDef[];
inlineEmbed: AttributeDef[];
blockEmbed: AttributeDef[];
} = {
text: [
{ name: 'color', values: ['#ffffff', '#000000', '#ff0000', '#ffff00'] },
{ name: 'bold', values: [true] },
{ name: 'code', values: [true] },
// @ts-expect-error
{ name: 'font', values: FontClass.whitelist },
// @ts-expect-error
{ name: 'size', values: SizeClass.whitelist },
],
newline: [
// @ts-expect-error
{ name: 'align', values: AlignClass.whitelist },
{ name: 'header', values: [1, 2, 3, 4, 5] },
{ name: 'blockquote', values: [true] },
{ name: 'list', values: ['ordered', 'bullet', 'checked', 'unchecked'] },
],
inlineEmbed: [
{ name: 'width', values: ['100', '200', '300'] },
{ name: 'height', values: ['100', '200', '300'] },
],
blockEmbed: [
{ name: 'width', values: ['100', '200', '300'] },
{ name: 'height', values: ['100', '200', '300'] },
],
};

const isLineFinished = (delta: Delta) => {
const lastOp = delta.ops[delta.ops.length - 1];
if (!lastOp) return false;
if (typeof lastOp.insert === 'string') {
return lastOp.insert.endsWith('\n');
}
if (typeof lastOp.insert === 'object') {
const key = Object.keys(lastOp.insert)[0];
return key === BLOCK_EMBED_NAME;
}
throw new Error('invalid op');
};

const generateAttributes = (scope: keyof typeof attributeDefs) => {
const attributeCount =
scope === 'newline'
? // Some block-level formats are exclusive so we only pick one for now for simplicity
choose([0, 0, 1])
: choose([0, 0, 0, 0, 0, 1, 2, 3, 4]);
const attributes: AttributeMap = {};
for (let i = 0; i < attributeCount; i += 1) {
const def = choose(attributeDefs[scope]);
attributes[def.name] = choose(def.values);
}
return attributes;
};

const generateRandomText = () => {
return choose([
'hi',
'world',
'Slab',
' ',
'this is a long text that contains spaces',
]);
};

type SingleInsertValue =
| string
| { [INLINE_EMBED_NAME]: string }
| { [BLOCK_EMBED_NAME]: string };

const generateSingleInsertDelta = (): Delta['ops'][number] & {
insert: SingleInsertValue;
} => {
const operation = choose<keyof typeof attributeDefs>([
'text',
'text',
'text',
'newline',
'inlineEmbed',
'blockEmbed',
]);

let insert: SingleInsertValue;
switch (operation) {
case 'text':
insert = generateRandomText();
break;
case 'newline':
insert = '\n';
break;
case 'inlineEmbed':
insert = { [INLINE_EMBED_NAME]: 'https://example.com' };
break;
case 'blockEmbed': {
insert = { [BLOCK_EMBED_NAME]: 'https://example.com' };
break;
}
}

const attributes = generateAttributes(operation);
const op: Op & { insert: SingleInsertValue } = { insert };
if (Object.keys(attributes).length) {
op.attributes = attributes;
}
return op;
};

const safePushInsert = (delta: Delta) => {
const op = generateSingleInsertDelta();
if (typeof op.insert === 'object' && op.insert[BLOCK_EMBED_NAME]) {
delta.insert('\n');
}
delta.push(op);
};

const generateDocument = () => {
const delta = new Delta();
const operationCount = 2 + randomInt(20);
for (let i = 0; i < operationCount; i += 1) {
safePushInsert(delta);
}
return delta;
};

const generateChange = (doc: Delta, changeCount: number) => {
const docLength = doc.length();
const skipLength = randomInt(docLength);
let change = new Delta().retain(skipLength);
const action = choose(['insert', 'delete', 'retain']);
const nextOp = doc.slice(skipLength).ops[0];
if (!nextOp) throw new Error('nextOp expected');
const needNewline = !isLineFinished(doc.slice(0, skipLength));
switch (action) {
case 'insert': {
const delta = new Delta();
const operationCount = randomInt(5) + 1;
for (let i = 0; i < operationCount; i += 1) {
safePushInsert(delta);
}
if (
needNewline ||
(typeof nextOp.insert === 'object' && !!nextOp.insert[BLOCK_EMBED_NAME])
) {
delta.insert('\n');
}
change = change.concat(delta);
break;
}
case 'delete': {
const lengthToDelete = randomInt(docLength - skipLength - 1) + 1;
const nextOpAfterDelete = doc.slice(skipLength + lengthToDelete).ops[0];
if (
needNewline &&
(!nextOpAfterDelete ||
(typeof nextOpAfterDelete.insert === 'object' &&
!!nextOpAfterDelete.insert[BLOCK_EMBED_NAME]))
) {
change.insert('\n');
}
change.delete(lengthToDelete);
break;
}
case 'retain': {
const retainLength =
typeof nextOp.insert === 'string'
? randomInt(nextOp.insert.length - 1) + 1
: 1;
if (typeof nextOp.insert === 'string') {
if (
nextOp.insert.includes('\n') &&
nextOp.insert.replace(/\n/g, '').length
) {
break;
}
if (nextOp.insert.includes('\n')) {
change.retain(
retainLength,
AttributeMap.diff(nextOp.attributes, generateAttributes('newline')),
);
} else {
change.retain(
retainLength,
AttributeMap.diff(nextOp.attributes, generateAttributes('text')),
);
}
break;
}
break;
}
}
changeCount -= 1;
return changeCount <= 0
? change
: change.compose(generateChange(doc.compose(change), changeCount));
};

describe('applyDelta', () => {
it('random', () => {
const container = document.createElement('div');
const quill = new Quill(container);
quill.setContents(generateDocument());
for (let i = 0; i < 1000; i += 1) {
const doc = quill.getContents();
const change = generateChange(doc, randomInt(4) + 1);
const diff = quill.updateContents(change);
expect(change).toEqual(diff);
}
});
});
Loading

0 comments on commit d4c1883

Please sign in to comment.