Skip to content

Instantly share code, notes, and snippets.

@jasonLaster
Created March 4, 2023 23:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonLaster/b693ea2ca9a0a2f327befb9256aadbc4 to your computer and use it in GitHub Desktop.
Save jasonLaster/b693ea2ca9a0a2f327befb9256aadbc4 to your computer and use it in GitHub Desktop.
/* Copyright 2022 Record Replay Inc. */
import { sample, sampleSize, random, flattenDeep, flatten } from "lodash";
import { MsPerSecond } from "../shared/utils";
import { defer } from "../shared/promise";
import { assert } from "../shared/assert";
import { waitForTime } from "../shared/timer";
import {
CommandMethods,
CommandParams,
CommandResult,
createPauseResult,
ExecutionPoint,
findStepOverTargetResult,
Location,
newSource,
NodeBounds,
Object as ProtocolObject,
PauseData,
SameLineSourceLocations,
TimeStampedPoint,
} from "@replayio/protocol";
import sharp from "sharp";
import { pingTelemetry } from "../shared/instanceUtils";
import { RecordingClient, uploadSessionScript } from "./recording-client";
import { logDebug } from "../shared/logger";
function log(label: string, args = {}) {
console.log(new Date(), label, JSON.stringify(args));
}
function logError(label: string, error: unknown) {
console.error(new Date(), label, error);
}
const lowEnd = 3;
const highEnd = 10;
async function withTimeout<T>(name: string, cbk: () => Promise<T>) {
return Promise.race([
cbk(),
waitForTime(60 * 1000).then(() => {
throw new Error(`Method timedout ${name}`);
}),
]);
}
async function selectSample<T, U>(
items: Array<T>,
cbk: (item: T) => Promise<U>
): Promise<Array<U>> {
const n = random(lowEnd, highEnd);
const sampledItems = sampleSize(items, n);
const results = [];
for (let i = 0; i < sampledItems.length; i++) {
const result = await cbk(sampledItems[i]);
results.push(result);
}
// const results = await Promise.all(sampledItems.map(item => cbk(item)));
return [...results];
}
async function randomTimes<T>(item: T, cbk: (item: T) => Promise<T>): Promise<Array<T>> {
const n = random(lowEnd, highEnd);
const results = [item];
for (let i = 0; i < n; i++) {
results.push(await cbk(results[results.length - 1]));
}
return results;
}
function comparePoints(p1: string, p2: string) {
const b1 = BigInt(p1);
const b2 = BigInt(p2);
return b1 < b2 ? -1 : b1 > b2 ? 1 : 0;
}
function findRectangle(
pixelTest: (x: number, y: number) => boolean,
width: number,
height: number,
minSize = 8
) {
for (let x = 0; x < width; x += minSize) {
for (let y = 0; y < width; y += minSize) {
if (pixelTest(x, y)) {
const rect = growRectangle(x, y);
if (rect.right - rect.left > minSize && rect.bottom - rect.top > minSize) {
return rect;
}
}
}
}
function growRectangle(x: number, y: number) {
let left = x;
while (left > 0 && pixelTest(left - 1, y)) {
left--;
}
let right = x;
while (right < width - 1 && pixelTest(right + 1, y)) {
right++;
}
const lineTest = (y: number) => {
for (let x = left; x < right; x++) {
if (!pixelTest(x, y)) {
return false;
}
}
return true;
};
let top = y;
while (top > 0 && lineTest(top - 1)) {
top--;
}
let bottom = y;
while (bottom < height - 1 && lineTest(bottom + 1)) {
bottom++;
}
return { left, right, top, bottom };
}
}
// This is the algorithm that is also used by the node picker in devtools
function pickNode(elements: NodeBounds[], x: number, y: number) {
for (const { node, rect, rects, clipBounds, visibility, pointerEvents } of elements) {
if (visibility === "hidden" || pointerEvents === "none") {
continue;
}
if (
(clipBounds?.left !== undefined && x < clipBounds.left) ||
(clipBounds?.right !== undefined && x > clipBounds.right) ||
(clipBounds?.top !== undefined && y < clipBounds.top) ||
(clipBounds?.bottom !== undefined && y > clipBounds.bottom)
) {
continue;
}
for (const r of rects || [rect]) {
const [left, top, right, bottom] = r;
if (x >= left && x <= right && y >= top && y <= bottom) {
return node;
}
}
}
}
interface PauseInfo extends TimeStampedPoint {
pauseId: string;
}
export class Fuzzer {
dispatchAddress: string;
recordingId: string;
url: string | undefined;
analysis: Map<string, Array<string>> = new Map();
sessionId: string | undefined;
client: RecordingClient | undefined;
constructor(dispatchAddress: string, recordingId: string, url?: string) {
this.dispatchAddress = dispatchAddress;
this.recordingId = recordingId;
this.url = url;
}
async destroy() {
const { client, sessionId } = this;
if (client) {
if (sessionId) {
await this.sendCommand("Recording.releaseSession", { sessionId });
}
client.close();
}
log("ReplayRecording Finished");
}
// Creates a session and replays the recording. Returns null if there was an error
// while replaying.
async setup(options: ReplayOptions): Promise<string | null> {
const { recordingId } = this;
this.client = new RecordingClient(this.dispatchAddress, {
onError: e => log(`Socket error ${e}`, { recordingId }),
onClose: (code, reason) => log(`Socket closed`, { code, reason, recordingId }),
});
const successWaiter = defer<boolean>();
this.client.addEventListener("Recording.sessionError", e => {
log(`sessionError`, { error: e, recordingId });
successWaiter.resolve(false);
});
this.client.addEventListener("Recording.uploadedData", () => {
// No-op handler so that we don't log warnings about unknown messages if the
// recording is still in the process of uploading when this script runs.
});
if (options.apiKey) {
await this.sendCommand("Authentication.setAccessToken", {
accessToken: options.apiKey,
});
}
let experimentalSettings;
const result = await this.sendCommand("Recording.createSession", {
recordingId,
experimentalSettings,
});
const { sessionId } = result;
log(`New Session`, { sessionId, recordingId });
this.client
.sendCommand("Session.ensureProcessed", { level: "basic" }, sessionId)
.then(
() => successWaiter.resolve(true),
err => {
logError("Failed to ensure processed", { error: err });
successWaiter.resolve(false);
}
);
const success = await successWaiter.promise;
if (!success) {
return null;
}
this.sessionId = sessionId;
return sessionId;
}
async sendCommandWithTimeout<M extends CommandMethods>(
command: M,
params: CommandParams<M>,
sessionId?: string,
pauseId?: string
) {
return Promise.race([
this.sendCommand(command, params, sessionId, pauseId),
waitForTime(20 * 1000).then(() => {
throw new Error(`Command timedout ${command}`);
}),
]);
}
async sendCommand<M extends CommandMethods>(
command: M,
params: CommandParams<M>,
sessionId?: string,
pauseId?: string
): Promise<CommandResult<M>> {
let result;
try {
const initialTime = new Date();
assert(this.client);
result = await this.client.sendCommand(command, params, sessionId, pauseId);
const duration = +new Date() - +initialTime;
log(`sendCommand ${command}`, {
duration,
params,
sessionId: this.sessionId,
pauseId,
recordingId: this.recordingId,
});
} catch (e) {
logError(`Command failed ${command}`, { error: e, recordingId: this.recordingId });
throw e;
}
return result;
}
pingTelemetry(event: string, tags: any = {}) {
pingTelemetry(event, {
recordingId: this.recordingId,
sessionId: this.sessionId,
...tags,
service_name: "testinbox",
});
}
async fetchEnabledFeatures() {
const { sessionId } = this;
assert(sessionId);
const result = await this.sendCommand("Session.getBuildId", {}, sessionId);
const isGecko = result.buildId.includes("gecko");
const isChromium = result.buildId.includes("chromium");
return {
elements: isGecko,
graphics: isGecko || isChromium,
};
}
async fetchSources() {
const { client, sessionId } = this;
assert(client);
// sources are files that can have possible breakpoint locations in them
const sources: Array<newSource> = [];
client.addEventListener("Debugger.newSource", source => {
sources.push(source);
});
await this.sendCommand("Debugger.findSources", {}, sessionId);
return sources;
}
async getPossibleBreakpoints(
source: newSource
): Promise<Array<SameLineSourceLocations>> {
assert(this.client);
const result = await this.sendCommand(
"Debugger.getPossibleBreakpoints",
{ sourceId: source.sourceId },
this.sessionId
);
return result.lineLocations;
}
async getLogpoints(location: Location): Promise<Array<PauseInfo>> {
try {
const values: Array<PauseInfo> = [];
const initialTime = new Date();
assert(this.client);
assert(this.sessionId);
this.client.addEventListener("Analysis.analysisResult", ({ results }) => {
values.push(...results.map(r => r.value));
});
this.client.addEventListener("Analysis.analysisPoints", _ => {});
const result = await this.sendCommand(
"Analysis.createAnalysis",
{
mapper: `
const { point, time, pauseId } = input;
return [{
key: point,
value: { time, pauseId, point }
}];`,
effectful: true,
},
this.sessionId
);
const { analysisId } = result;
this.analysis.set(analysisId, []);
await this.sendCommand(
"Analysis.addLocation",
{
location,
analysisId,
},
this.sessionId
);
await Promise.all([
this.sendCommandWithTimeout(
"Analysis.runAnalysis",
{ analysisId },
this.sessionId
),
this.sendCommandWithTimeout(
"Analysis.findAnalysisPoints",
{ analysisId },
this.sessionId
),
]);
log(`Logpoints results`, {
duration: +new Date() - +initialTime,
results: values.length,
recordingId: this.recordingId,
});
return values;
} catch (e) {
log(`Analysis failed for location`, { location });
console.error(e);
return [];
}
}
async step(fromPoint: ExecutionPoint): Promise<TimeStampedPoint> {
const directions: CommandMethods[] = ["Debugger.findStepOverTarget"];
const dir = sample(directions);
assert(dir);
const result = await this.sendCommand(dir, { point: fromPoint }, this.sessionId);
const { point, time } = (result as findStepOverTargetResult).target;
return { point, time };
}
async expandObject(object: ProtocolObject | undefined, pause: createPauseResult) {
if (!object || !object.objectId) {
return;
}
const preview = await this.sendCommand(
"Pause.getObjectPreview",
{ object: object.objectId },
this.sessionId,
pause.pauseId
);
const newObject = sample(preview.data.objects);
return newObject;
}
fetchPause(point: ExecutionPoint): Promise<createPauseResult> {
const result = this.sendCommand("Session.createPause", { point }, this.sessionId);
return result;
}
// place logpoints in random locations for a given source
async randomLogpoints(source: newSource): Promise<Array<Array<PauseInfo>>> {
const lineLocations = await this.getPossibleBreakpoints(source);
return selectSample(lineLocations, async ({ line, columns }) => {
const column = sample(columns);
assert(typeof column === "number");
const location = { sourceId: source.sourceId, line, column };
const logpoints = await this.getLogpoints(location);
return logpoints;
});
}
async getEndpoint() {
const { endpoint } = await this.sendCommand(
"Session.getEndpoint",
{},
this.sessionId
);
return endpoint;
}
async getBody(pauseId: string) {
const { document } = await this.sendCommand(
"DOM.getDocument",
{},
this.sessionId,
pauseId
);
const { result, data } = await this.sendCommand(
"DOM.querySelector",
{ node: document, selector: "body" },
this.sessionId,
pauseId
);
return data.objects?.find(o => o.objectId === result);
}
async getObject(objectId: string, pauseId: string) {
const res = await this.sendCommand(
"Pause.getObjectPreview",
{ object: objectId },
this.sessionId,
pauseId
);
return res.data.objects?.find(o => o.objectId === objectId);
}
async getChildNodeIds(nodeId: string, pauseId: string) {
const object = await this.getObject(nodeId, pauseId);
return object?.preview?.node?.childNodes || [];
}
async loadStyles(nodeId: string, pauseId: string) {
const node = nodeId;
try {
await Promise.all([
this.sendCommand("CSS.getComputedStyle", { node }, this.sessionId, pauseId),
this.sendCommand("CSS.getAppliedRules", { node }, this.sessionId, pauseId),
this.sendCommand("DOM.getEventListeners", { node }, this.sessionId, pauseId),
this.sendCommand("DOM.getBoxModel", { node }, this.sessionId, pauseId),
this.sendCommand("DOM.getBoundingClientRect", { node }, this.sessionId, pauseId),
]);
} catch (e: any) {
log(`Load styles for node ${nodeId} failed`, e.message);
}
}
async loadBoundingClientRects(pauseId: string) {
try {
const result = await this.sendCommand(
"DOM.getAllBoundingClientRects",
{},
this.sessionId,
pauseId
);
log(`getAllBoundingClientRects results`, {
elementCount: result.elements.length,
pauseId: pauseId,
sessionId: this.sessionId,
});
return result.elements;
} catch (e: any) {
log(`getAllBoundingClientRects failed`, e.message);
}
}
async repaintGraphics(pauseId: string) {
try {
const result = await this.sendCommand(
"DOM.repaintGraphics",
{},
this.sessionId,
pauseId
);
log(`repaintGraphics result`, { keys: Object.keys(result) });
return result;
} catch (e: any) {
log(`repaintGraphics failed`, e.message);
}
}
async repaintAndLookForBlackRectangle(pause: PauseInfo, paintPoints: string[]) {
const repaintResult = await this.repaintGraphics(pause.pauseId);
if (!repaintResult?.screenShot?.data) {
return;
}
const closestPaintPoint = paintPoints.find(p => comparePoints(p, pause.point) >= 0);
if (!closestPaintPoint) {
return;
}
const graphicsResult = await this.loadGraphics(closestPaintPoint);
const recordedImage = sharp(Buffer.from(graphicsResult.screen.data, "base64"));
const { width, height } = await recordedImage.metadata();
if (!width || !height) {
return;
}
const repaintedImage = sharp(Buffer.from(repaintResult.screenShot.data, "base64"));
const { width: width2, height: height2 } = await repaintedImage.metadata();
if (width2 !== width || height2 !== height) {
return;
}
const recordedPixels = await recordedImage.raw().toBuffer();
const repaintedPixels = await repaintedImage.raw().toBuffer();
const pixelTest = (x: number, y: number) => {
const offset = (x + y * width) * 3;
return (
repaintedPixels[offset] === 0 &&
repaintedPixels[offset + 1] === 0 &&
repaintedPixels[offset + 2] === 0 &&
(recordedPixels[offset] !== 0 ||
recordedPixels[offset + 1] !== 0 ||
recordedPixels[offset + 2] !== 0)
);
};
const rect = findRectangle(pixelTest, width, height);
if (rect) {
log("Found a black rectangle", rect);
this.pingTelemetry("fuzzer.BlackRectangleInRepaintedGraphics", {
point: pause.point,
time: pause.time,
...rect,
});
}
}
loadRandomNodes = (pauseId: string) =>
withTimeout(`loadRandomNodes for ${pauseId}`, async () => {
const body = await this.getBody(pauseId);
let childNodeIds = body?.preview?.node?.childNodes || [];
// we descend 10 levels into the document and on each level we load styling information
// of all children of the nodes selected on the previous level and randomly select
// some children for the next level
for (let i = 0; i < highEnd; i++) {
// Only load styles for one node at a time, which is what will happen when people
// are using the devtools.
for (const nodeId of childNodeIds) {
await this.loadStyles(nodeId, pauseId);
}
childNodeIds = flatten(
await selectSample(childNodeIds, nodeId =>
this.getChildNodeIds(nodeId, pauseId)
)
);
}
});
loadFrames(pauseId: string) {
return this.sendCommand("Pause.getAllFrames", {}, this.sessionId, pauseId);
}
loadFrameSteps(pauseId: string, frameId: string) {
return this.sendCommand("Pause.getFrameSteps", { frameId }, this.sessionId, pauseId);
}
loadScope(pauseId: string, scopeId: string) {
return this.sendCommand(
"Pause.getScope",
{ scope: scopeId },
this.sessionId,
pauseId
);
}
evaluateInFrame(pauseId: string, frameId: string, expression: string) {
return this.sendCommand(
"Pause.evaluateInFrame",
{ frameId, expression },
this.sessionId,
pauseId
);
}
evaluateInGlobal(pauseId: string, expression: string) {
return this.sendCommand(
"Pause.evaluateInGlobal",
{ expression },
this.sessionId,
pauseId
);
}
async loadPaintPoints() {
const promise = this.sendCommand("Graphics.findPaints", {}, this.sessionId);
const paintPoints: string[] = [];
assert(this.client);
this.client.addEventListener("Graphics.paintPoints", result => {
paintPoints.push(...result.paints.map(paint => paint.point));
});
await promise;
paintPoints.sort(comparePoints);
return paintPoints;
}
loadGraphics(point: string) {
return this.sendCommand(
"Graphics.getPaintContents",
{ mimeType: "image/jpeg", point },
this.sessionId
);
}
async getWindowSize(pauseId: string) {
const width = (await this.evaluateInGlobal(pauseId, "innerWidth")).result?.returned
?.value;
const height = (await this.evaluateInGlobal(pauseId, "innerHeight")).result?.returned
?.value;
return { width, height };
}
async checkNodeAtPosition(
pauseInfo: PauseInfo,
elements: NodeBounds[],
x: number,
y: number
) {
const reason = await this.isPickingWrongNode(pauseInfo.pauseId, elements, x, y);
if (reason) {
const url = `https://app.replay.io/recording/${this.recordingId}?point=${pauseInfo.point}&time=${pauseInfo.time}&hasFrames=true`;
const locationResult = await this.evaluateInGlobal(
pauseInfo.pauseId,
"location.href"
);
const recordingUrl = locationResult.result.returned?.value;
log("Our stacking algorithm picked the wrong node", {
recordingId: this.recordingId,
sessionId: this.sessionId,
point: pauseInfo.point,
time: pauseInfo.time,
x,
y,
recordingUrl,
url,
reason,
});
this.pingTelemetry("fuzzer.PickedWrongNode", {
point: pauseInfo.point,
time: pauseInfo.time,
x,
y,
recordingUrl,
url,
reason,
});
}
}
async isPickingWrongNode(pauseId: string, bounds: NodeBounds[], x: number, y: number) {
const { result } = await this.evaluateInGlobal(
pauseId,
`document.elementFromPoint(${x}, ${y})`
);
const nodeId = result.returned?.object;
if (!nodeId) {
// elementFromPoint didn't pick a node
return "no-reference-node";
}
const pickedNodeId = pickNode(bounds, x, y);
if (!pickedNodeId) {
// our algorithm didn't pick a node
return "no-picked-node";
}
if (pickedNodeId === nodeId) {
// we picked the right node!
return false;
}
// is the node picked by our algorithm in an iframe?
const parentsResult = await this.sendCommand(
"DOM.getParentNodes",
{ node: pickedNodeId },
this.sessionId,
pauseId
);
const nodes = parentsResult.data.objects || [];
if (result.data.objects) {
nodes.push(...result.data.objects);
}
let currentNodeId: string | undefined = pickedNodeId;
while (currentNodeId) {
const currentNode = nodes.find(node => node.objectId === currentNodeId);
if (!currentNode) {
break;
}
if (currentNode.className === "HTMLIFrameElement") {
const currentNodeBounds = bounds.find(element => element.node === currentNodeId);
if (!currentNodeBounds) {
break;
}
const [left, top, right, bottom] = currentNodeBounds.rect;
if (x < left || x > right || y < top || y > bottom) {
// our algorithm picked a node inside an iframe but the coordinates
// are outside the iframe's bounds
return "iframe-overflow-693";
}
if (currentNodeBounds.pointerEvents === "none") {
// our algorithm picked a node inside an iframe but the iframe has
// pointer-events set to "none", which should be applied to all children
return "iframe-pointerevents-692";
}
break;
}
currentNodeId = currentNode.preview?.node?.parentNode;
}
const nodeName = (
await this.evaluateInGlobal(
pauseId,
`document.elementFromPoint(${x}, ${y}).nodeName`
)
).result.returned?.value;
if (nodeName === "IFRAME") {
// elementFromPoint picked an iframe
return "iframe";
}
if (this.findClassName(nodeId, result.data)?.startsWith("SVG")) {
// elementFromPoint picked an svg element
return "svg";
}
const pickedNodePreview = await this.sendCommand(
"Pause.getObjectPreview",
{ object: pickedNodeId },
this.sessionId,
pauseId
);
const pickedNodeClass = this.findClassName(pickedNodeId, pickedNodePreview.data);
if (pickedNodeClass?.startsWith("SVG")) {
// our algorithm picked an svg element
return "svg2";
}
if (pickedNodeClass === "HTMLOptionElement") {
// our algorithm picked an option element
return "option-698";
}
const contentBefore = (
await this.evaluateInGlobal(
pauseId,
`window.getComputedStyle(document.elementFromPoint(${x}, ${y}), ":before")["content"]`
)
).result.returned?.value;
const contentAfter = (
await this.evaluateInGlobal(
pauseId,
`window.getComputedStyle(document.elementFromPoint(${x}, ${y}), ":after")["content"]`
)
).result.returned?.value;
if (contentBefore !== "none" || contentAfter !== "none") {
// elementFromPoint picked an element with a :before or :after pseudo element,
// this may be the reason why the picked element has a larger selectable area,
// although we can't be sure
return "pseudo-element-699";
}
const { computedStyle } = await this.sendCommand(
"CSS.getComputedStyle",
{ node: pickedNodeId },
this.sessionId,
pauseId
);
const clipValue = computedStyle.find(r => r.name === "clip")?.value;
if (clipValue && clipValue !== "auto") {
// our algorithm picked an element with the CSS clip property
return "clip-700";
}
const { rect } = await this.sendCommand(
"DOM.getBoundingClientRect",
{ node: nodeId },
this.sessionId,
pauseId
);
const [left, top, right, bottom] = rect;
if (x < left || x > right || y < top || y > bottom) {
const rectResult = await this.evaluateInGlobal(
pauseId,
`(() => {
const el = document.elementFromPoint(${x}, ${y});
const range = document.createRange();
range.setStart(el, 0);
range.setEnd(el, el.childNodes.length);
return range.getBoundingClientRect();
})()`
);
const values = rectResult.result.data.objects?.[0].preview?.getterValues;
const findValue = (name: string) => values?.find(v => v.name === name)?.value;
if (
x < findValue("left") ||
x > findValue("right") ||
y < findValue("top") ||
y > findValue("bottom")
) {
// the coordinates are outside the bounds of the node picked by
// elementFromPoint but within the bounds of the text in that node
return "text-outside-bounds-694";
}
return "outside-bounds";
}
return "unknown";
}
findClassName(objectId: string, data: PauseData) {
for (const objPreview of data.objects || []) {
if (objPreview.objectId === objectId) {
return objPreview.className;
}
}
}
}
type ReplayOptions = {
skipFuzzing?: boolean;
apiKey?: string;
uploadSessionRecording?: boolean;
testSnapshotCallback?: (
sessionId: string,
snapshotsCreated: number,
snapshotsRestored: number,
crashesAfterRestore: number
) => void;
};
// Replay a recording making a random number of random decisions, like which logpoints to set and how many times to step
// if seed is passed uses that seed for all RNG calls. Useful for reproducing errors that are observed in the logs after the fact
export async function replayRecording(
dispatchAddress: string,
recordingId: string,
url?: string,
options: ReplayOptions = {}
): Promise<boolean> {
log("Replay recording", recordingId);
let sessionId: string | null = null;
const fuzzer = new Fuzzer(dispatchAddress, recordingId, url);
const startTime = new Date();
let success = false;
try {
sessionId = await fuzzer.setup(options);
if (!sessionId) {
await fuzzer.destroy();
return false;
}
if (!options.skipFuzzing) {
const features = await fuzzer.fetchEnabledFeatures();
const sources = await fuzzer.fetchSources();
const paintPoints = await fuzzer.loadPaintPoints();
log("\n## Randomly add logpoints in various files", { recordingId, sessionId });
const pauseInfos: Array<PauseInfo> = flattenDeep(
await selectSample(sources, async source => fuzzer.randomLogpoints(source))
);
log("\n## Randomly step through various points", { recordingId, sessionId });
const stepPoints = flattenDeep(
await selectSample(pauseInfos, info =>
randomTimes<TimeStampedPoint>(info, item => fuzzer.step(item.point))
)
);
const endPoint = await fuzzer.getEndpoint();
const points = [...pauseInfos, ...stepPoints, endPoint];
log("\n## Randomly fetch frames for various pauses", { recordingId, sessionId });
await selectSample(pauseInfos, info => fuzzer.loadFrames(info.pauseId));
log("\n## Randomly fetch pauses for various points", { recordingId, sessionId });
const pauses = await selectSample(points, async ({ point, time }) => {
const pause = await fuzzer.fetchPause(point);
return { point, time, ...pause };
});
log("\n## Randomly fetch frame steps for various pauses", {
recordingId,
sessionId,
});
await selectSample(pauses, pause =>
selectSample(pause.data.frames!, frame =>
fuzzer.loadFrameSteps(pause.pauseId, frame.frameId)
)
);
log("\n## Randomly evaluate some global expressions in various pauses");
await selectSample(pauses, async pause => {
await fuzzer.evaluateInGlobal(pause.pauseId, "location.href");
await fuzzer.evaluateInGlobal(
pause.pauseId,
'document.querySelectorAll("script")'
);
});
log(
"\n## Randomly fetch scopes and evaluate variables in them for various pauses",
{ recordingId, sessionId }
);
await selectSample(pauses, pause =>
selectSample(pause.data.frames!, frame =>
selectSample(frame.scopeChain, async scopeId => {
const scope = await fuzzer.loadScope(pause.pauseId, scopeId);
const bindings = scope.data.scopes?.[0].bindings;
if (bindings) {
await selectSample(bindings, ({ name }) =>
fuzzer.evaluateInFrame(pause.pauseId, frame.frameId, name)
);
}
})
)
);
log("\n## Randomly expands objects for various pauses", { recordingId, sessionId });
await selectSample(pauses, pause =>
selectSample(pause.data.objects!, object =>
randomTimes<ProtocolObject | undefined>(object, object =>
fuzzer.expandObject(object, pause)
)
)
);
if (features.graphics) {
log("\n## Randomly repaint and look for black rectangles", {
recordingId,
sessionId,
});
await selectSample(pauses, async pause => {
fuzzer.repaintAndLookForBlackRectangle(pause, paintPoints);
});
}
if (features.elements) {
log("\n## Randomly fetch some DOM nodes", { recordingId, sessionId });
await selectSample(pauses, pause => fuzzer.loadRandomNodes(pause.pauseId));
log(
"\n## Randomly pick nodes and check that our stacking algorithm picks the same",
{ recordingId, sessionId }
);
await selectSample(pauses, async pause => {
const { width, height } = await fuzzer.getWindowSize(pause.pauseId);
const elements = await fuzzer.loadBoundingClientRects(pause.pauseId);
if (!elements) {
return;
}
for (let i = 0; i < 20; i++) {
await fuzzer.checkNodeAtPosition(
pause,
elements,
Math.floor(Math.random() * (width - 18)) + 1,
Math.floor(Math.random() * (height - 18)) + 1
);
}
});
}
}
log(`\n## Finished Replaying`, {
recordingId,
sessionId,
duration: +new Date() - +startTime,
});
success = true;
} catch (e) {
logError("Encountered error interacting with recording", {
error: e,
recordingId,
sessionId,
});
throw e;
} finally {
if (options.uploadSessionRecording && sessionId && fuzzer.client) {
uploadSessionScript(recordingId, sessionId, fuzzer.client.export()).catch(e => {
logError("Failed to upload session script", {
error: e,
recordingId,
sessionId,
});
});
} else {
logDebug<string>("Not uploading session script", {
fuzzerClient: Boolean(fuzzer.client),
recordingId,
sessionId,
uploadSessionScript: options.uploadSessionRecording,
});
}
await fuzzer.destroy();
}
return success;
}
export async function replayRecordingWithTimeout(
dispatchAddress: string,
recordingId: string,
url: string | undefined,
options: ReplayOptions,
timeoutSeconds: number
): Promise<boolean> {
let success = false;
await Promise.race([
replayRecording(dispatchAddress, recordingId, url, options).then(rv => {
success = rv;
}),
waitForTime(timeoutSeconds * MsPerSecond, { unref: true }),
]);
return success;
}
// Scratchpad for testing
if (process.argv[1].includes("replayRecording")) {
let recordingId: string | undefined;
let url: string | undefined;
let server: string | undefined = "wss://dispatch.replay.io";
const options: ReplayOptions = {
apiKey: process.env.RECORD_REPLAY_API_KEY,
};
const args = process.argv.slice(2);
let arg;
while ((arg = args.shift()?.toLowerCase())) {
switch (arg) {
case "-r":
case "--recording":
recordingId = args.shift();
assert(recordingId && !recordingId.startsWith("-"), "Recording ID missing");
break;
case "-u":
case "--url":
url = args.shift();
assert(url && !url.startsWith("-"), "URL is missing");
break;
case "-s":
case "--server":
server = args.shift();
assert(server && !server.startsWith("-"), "server is missing");
break;
case "-a":
case "--api-key":
options.apiKey = args.shift();
assert(options.apiKey && !options.apiKey.startsWith("-"), "API Key is missing");
break;
}
}
assert(recordingId, "No recording specified");
replayRecording(server, recordingId, url, options).catch(() => {
console.error("Fuzzing failed");
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment