Created
July 28, 2023 18:20
-
-
Save jasonLaster/b0688ca5882853e4956706d9973f7b99 to your computer and use it in GitHub Desktop.
Fuzzer
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Copyright 2023 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, | |
Source, | |
NodeBounds, | |
Object as ProtocolObject, | |
PauseData, | |
runEvaluationResults, | |
SameLineSourceLocations, | |
TimeStampedPoint, | |
} from "@replayio/protocol"; | |
import sharp from "sharp"; | |
import { pingTelemetry } from "../shared/instanceUtils"; | |
import { RecordingClient } from "./recording-client"; | |
import { logDebug } from "../shared/logger"; | |
import { uploadSessionScript } from "./protocol-recorder"; | |
import type { UserExperimentalSettings } from "../shared/settings/user-experimental-settings"; | |
import { uuid } from "../shared/uuid/v4"; | |
const MAX_LOGPOINTS = 200; | |
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 = 1; | |
const highEnd = 5; | |
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; | |
sessionId: string | undefined; | |
client: RecordingClient | undefined; | |
buildId: string | 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, | |
experimentalSettings?: Partial<UserExperimentalSettings> | |
): 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, | |
}); | |
} | |
const result = await this.sendCommand("Recording.createSession", { | |
recordingId, | |
experimentalSettings, | |
}); | |
const { sessionId } = result; | |
const { buildId } = await this.sendCommand("Session.getBuildId", {}, sessionId); | |
this.buildId = buildId; | |
log(`New Session`, { sessionId, recordingId }); | |
this.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, | |
buildId: this.buildId, | |
recording: { | |
id: this.recordingId, | |
buildId: this.buildId, | |
}, | |
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<Source> = []; | |
client.addEventListener("Debugger.newSource", source => { | |
sources.push(source); | |
}); | |
client.addEventListener("Debugger.newSources", ({ sources: sourcesList }) => { | |
for (const source of sourcesList) { | |
sources.push(source); | |
} | |
}); | |
await this.sendCommand("Debugger.findSources", {}, sessionId); | |
return sources; | |
} | |
async getPossibleBreakpoints(source: Source): 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); | |
const id = uuid(); | |
const { promise: handlerErrorPromise, reject } = defer<never>(); | |
const handler = ({ runEvaluationId, results }: runEvaluationResults) => { | |
if (runEvaluationId !== id) { | |
return; | |
} | |
try { | |
values.push( | |
...results.map(r => ({ | |
...r.point, | |
pauseId: r.pauseId, | |
})) | |
); | |
assert(values.length <= MAX_LOGPOINTS); | |
} catch (err) { | |
reject(err); | |
} | |
}; | |
this.client.addEventListener("Session.runEvaluationResults", handler); | |
try { | |
let begin: ExecutionPoint | undefined; | |
while (values.length < MAX_LOGPOINTS) { | |
const { pointPage } = await Promise.race([ | |
handlerErrorPromise, | |
this.sendCommand( | |
"Session.runEvaluation", | |
{ | |
runEvaluationId: id, | |
pointSelector: { | |
kind: "location", | |
location, | |
}, | |
pointLimits: { | |
begin, | |
maxCount: MAX_LOGPOINTS - values.length, | |
}, | |
expression: "", | |
frameIndex: 0, | |
}, | |
this.sessionId | |
), | |
]); | |
if (pointPage.begin === pointPage.end || !pointPage.hasNext) { | |
break; | |
} | |
begin = pointPage.end; | |
} | |
} finally { | |
this.client.removeEventListener("Session.runEvaluationResults", handler); | |
} | |
log(`Logpoints results`, { | |
duration: +new Date() - +initialTime, | |
results: values.length, | |
recordingId: this.recordingId, | |
}); | |
return values; | |
} catch (e) { | |
log(`runEvaluation 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: Source): 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; | |
} | |
} | |
} | |
} | |
export 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 = {}, | |
experimentalSettings?: Partial<UserExperimentalSettings> | |
): 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, experimentalSettings); | |
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 { pauseId } = await fuzzer.fetchPause(point); | |
const { frames, data } = await fuzzer.loadFrames(pauseId); | |
return { point, time, pauseId, stack: frames, data }; | |
}); | |
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, | |
}); | |
} 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, | |
experimentalSettings?: Partial<UserExperimentalSettings> | |
): Promise<boolean> { | |
let success = false; | |
await Promise.race([ | |
replayRecording( | |
dispatchAddress, | |
recordingId, | |
url, | |
options, | |
experimentalSettings | |
).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