gremlins-ts
Version:
A monkey testing library written in JavaScript, for Node.js and the browser. Use it to check the robustness of web applications by unleashing a horde of undisciplined gremlins.
422 lines (370 loc) • 11.6 kB
text/typescript
import { configurable } from "../utils";
import { RandomizerRequiredException } from "../exceptions";
/**
* The toucher gremlin touches anywhere on the visible area of the document.
*
* The toucher gremlin triggers touch events (touchstart, touchmove, touchcancel
* and touchend), by doing gestures on random targets displayed on the viewport.
* Touch gestures can last several seconds, so this gremlin isn't instantaneous.
*
* By default, the touch gremlin activity is showed by a red disc.
*
* var toucherGremlin = gremlins.species.toucher();
* horde.gremlin(toucherGremlin);
*
* The toucher gremlin can be customized as follows:
*
* toucherGremlin.touchTypes(['tap', 'gesture']); // the mouse event types to trigger
* toucherGremlin.positionSelector(function() { // find a random pair of coordinates to touch });
* toucherGremlin.showAction(function(x, y) { // show the gremlin activity on screen });
* toucherGremlin.canTouch(function(element) { return true }); // to limit where the gremlin can touch
* toucherGremlin.maxNbTries(5); // How many times the gremlin must look for a touchable element before quitting
* toucherGremlin.logger(loggerObject); // inject a logger
* toucherGremlin.randomizer(randomizerObject); // inject a randomizer
*
* Example usage:
*
* horde.gremlin(gremlins.species.toucher()
* .touchTypes(['gesture'])
* .positionSelector(function() {
* // only touch inside the foo element area
* var $el = $('#foo');
* var offset = $el.offset();
* return [
* parseInt(Math.random() * $el.outerWidth() + offset.left),
* parseInt(Math.random() * $el.outerHeight() + offset.top)
* ];
* })
* . showAction(function(x, y) {
* // do nothing (hide the gremlin action on screen)
* })
* );
*/
function defaultShowAction(touches: Touch[]) {
const { document } = window;
const { body } = document;
const fragment = document.createDocumentFragment();
touches.forEach(touch => {
const touchSignal = document.createElement("div");
touchSignal.style.zIndex = "2000";
touchSignal.style.background = "red";
touchSignal.style.borderRadius = "50%";
touchSignal.style.width = "20px";
touchSignal.style.height = "20px";
touchSignal.style.position = "absolute";
touchSignal.style.transition = "opacity .5s ease-out";
touchSignal.style.left = touch.x - 10 + "px";
touchSignal.style.top = touch.y - 10 + "px";
const element = fragment.appendChild(touchSignal);
setTimeout(() => body.removeChild(element), 500);
setTimeout(() => (element.style.opacity = "0"), 50);
});
document.body.appendChild(fragment);
}
function defaultCanTouch() {
return true;
}
/**
* generate a list of x/y around the center
*/
function getTouches(
[cx, cy]: [number, number],
points: number,
radius: number = 100,
maybeDegrees?: number | null
) {
const touches = [];
// just one touch, at the center
if (points === 1) {
return [{ x: cx, y: cy }];
}
const degrees = maybeDegrees ? (maybeDegrees * Math.PI) / 180 : 0;
const slice = (2 * Math.PI) / points;
for (let i = 0; i < points; i++) {
const angle = slice * i + degrees;
touches.push({
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle)
});
}
return touches;
}
type Touch = { x: number; y: number };
type Gesture = {
distanceX?: number;
distanceY?: number;
duration: number;
radius?: number;
rotation?: number | null;
scale?: number;
};
type Config = {
touchTypes: string[];
positionSelector: () => [number, number];
showAction: (touches: Touch[]) => void;
canTouch: (element: Element) => boolean;
logger: typeof console;
maxNbTries: number;
maxTouches: number;
randomizer: Chance.Chance | null;
};
export function toucher() {
const { document } = window;
const defaultTouchTypes = [
...new Array(3).fill("tap"),
...new Array(1).fill("doubletap"),
...new Array(3).fill("gesture"),
...new Array(2).fill("multitouch")
];
const config: Config = {
touchTypes: defaultTouchTypes,
positionSelector: defaultPositionSelector,
showAction: defaultShowAction,
canTouch: defaultCanTouch,
maxNbTries: 10,
logger: console,
randomizer: null,
maxTouches: 2
};
function defaultPositionSelector(): [number, number] {
if (config.randomizer === null) {
return [0, 0];
}
return [
config.randomizer.natural({
max: document.documentElement.clientWidth - 1
}),
config.randomizer.natural({
max: document.documentElement.clientHeight - 1
})
];
}
/**
* trigger a touchevent
* @param touches
* @param element
* @param type
*/
function triggerTouch(
touches: Touch[],
element: Element,
type: "start" | "end" | "move"
) {
const touchlist: Array<{
pageX: number;
pageY: number;
clientX: number;
clientY: number;
screenX: number;
screenY: number;
target: Element;
identifier: number;
}> = [];
function item(index: number) {
// @ts-ignore
return this[index] || {};
}
// @ts-ignore
touchlist.identifiedTouch = item;
// @ts-ignore
touchlist.item = item;
touches.forEach((touch, i) => {
const x = Math.round(touch.x);
const y = Math.round(touch.y);
touchlist.push({
pageX: x,
pageY: y,
clientX: x,
clientY: y,
screenX: x,
screenY: y,
target: element,
identifier: i
});
});
const event = document.createEvent("Event");
event.initEvent("touch" + type, true, true);
// @ts-ignore
event.touches = type == "end" ? [] : touchlist;
// @ts-ignore
event.targetTouches = type == "end" ? [] : touchlist;
// @ts-ignore
event.changedTouches = touchlist;
element.dispatchEvent(event);
config.showAction(touches);
}
/**
* trigger a gesture
*/
function triggerGesture(
element: Element,
startPos: [number, number],
startTouches: Touch[],
gesture: Gesture,
done: (touches: Touch[]) => void
) {
const interval = 10;
const loops = Math.ceil(gesture.duration / interval);
let loop = 1;
function gestureLoop() {
// calculate the radius
let {
radius = 0,
scale = 0,
distanceX = 0,
distanceY = 0,
rotation = null
} = gesture;
if (scale !== 1) {
radius = radius - radius * (1 - scale) * ((1 / loops) * loop);
}
// calculate new position/rotation
const posX = startPos[0] + (distanceX / loops) * loop;
const posY = startPos[1] + (distanceY / loops) * loop;
rotation = typeof rotation == "number" ? (rotation / loops) * loop : null;
const touches = getTouches(
[posX, posY],
startTouches.length,
radius,
rotation
);
const isFirst = loop == 1;
const isLast = loop == loops;
if (isFirst) {
triggerTouch(touches, element, "start");
} else if (isLast) {
triggerTouch(touches, element, "end");
return done(touches);
} else {
triggerTouch(touches, element, "move");
}
setTimeout(gestureLoop, interval);
loop++;
}
gestureLoop();
}
const touchTypes: {
[touchType: string]: (
position: [number, number],
element: Element,
done: (touches: Touch[], gesture: Gesture) => void
) => void;
} = {
// tap, like a click event, only 1 touch
// could also be a slow tap, that could turn out to be a hold
tap(position, element, done) {
const touches = getTouches(position, 1);
const gesture: Gesture = {
duration: config.randomizer
? config.randomizer.integer({ min: 20, max: 700 })
: 0
};
triggerTouch(touches, element, "start");
setTimeout(() => {
triggerTouch(touches, element, "end");
done(touches, gesture);
}, gesture.duration);
},
// doubletap, like a dblclick event, only 1 touch
// could also be a slow doubletap, that could turn out to be a hold
doubletap(position, element, done) {
touchTypes.tap(position, element, () => {
setTimeout(() => touchTypes.tap(position, element, done), 30);
});
},
// single touch gesture, could be a drag and swipe, with 1 points
gesture(position, element, done) {
const gesture: Gesture = {
distanceX: config.randomizer
? config.randomizer.integer({ min: 0, max: 300 }) - 100
: 0,
distanceY: config.randomizer
? config.randomizer.integer({ min: 0, max: 300 }) - 100
: 0,
duration: config.randomizer
? config.randomizer.integer({ min: 20, max: 500 })
: 0
};
const touches = getTouches(position, 1, gesture.radius);
triggerGesture(element, position, touches, gesture, touches =>
done(touches, gesture)
);
},
// multitouch gesture, could be a drag, swipe, pinch and rotate, with 2 or more points
multitouch(position, element, done) {
const points = config.randomizer
? config.randomizer.integer({
min: 2,
max: config.maxTouches
})
: 0;
const gesture: Gesture = {
scale: config.randomizer
? config.randomizer.floating({ min: 0, max: 2 })
: 0,
rotation: config.randomizer
? config.randomizer.natural({ min: 0, max: 200 }) - 100
: 0,
radius: config.randomizer
? config.randomizer.integer({ min: 50, max: 200 })
: 0,
distanceX: config.randomizer
? config.randomizer.integer({ min: 0, max: 40 }) - 20
: 0,
distanceY: config.randomizer
? config.randomizer.integer({ min: 0, max: 40 }) - 20
: 0,
duration: config.randomizer
? config.randomizer.integer({ min: 100, max: 1500 })
: 0
};
const touches = getTouches(position, points, gesture.radius);
triggerGesture(element, position, touches, gesture, touches =>
done(touches, gesture)
);
}
};
function toucherGremlin(done?: () => void) {
if (!config.randomizer) {
throw new RandomizerRequiredException();
}
let posX: number;
let posY: number;
let targetElement: Element | null;
let nbTries = 0;
do {
[posX, posY] = config.positionSelector();
targetElement = document.elementFromPoint(posX, posY);
nbTries++;
if (nbTries > config.maxNbTries) {
return;
}
} while (!targetElement || !config.canTouch(targetElement));
const touchType = config.randomizer.pick(config.touchTypes);
if (!(touchType in touchTypes)) {
throw new Error(`Invalid touch type "${touchType}"`);
}
touchTypes[touchType]([posX, posY], targetElement, logGremlin);
function logGremlin(touches: Touch[], details: any) {
if (typeof config.showAction === "function") {
config.showAction(touches);
}
if (config.logger && typeof config.logger.log === "function") {
config.logger.log(
"gremlin",
"toucher ",
touchType,
"at",
posX,
posY,
details
);
}
if (done) {
done();
}
}
}
configurable(toucherGremlin, config);
return toucherGremlin;
}