Last active
July 26, 2023 19:20
-
-
Save jasonLaster/3062d8ab6e4aead8c66ecb7cecb8dc33 to your computer and use it in GitHub Desktop.
Jump to Code
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. */ | |
// React Event Listeners routine event listener processing functions | |
import { | |
ObjectPreview, | |
Object as ProtocolObject, | |
getSourceOutlineResult, | |
Location, | |
PointDescription, | |
SourceLocation, | |
TimeStampedPoint, | |
MappedLocation, | |
FunctionOutline, | |
SameLineSourceLocations, | |
Property, | |
ClassOutline, | |
} from "@replayio/protocol"; | |
import groupBy from "lodash/groupBy"; | |
import { CachesCollection, RecordingTarget } from "../shared/caches/ProtocolDataCaches"; | |
import { ReplayClient, ReplayClientInterface } from "../shared/replayClient"; | |
import { | |
getPreferredLocation, | |
isLocationBefore, | |
SourceDetails, | |
SourcesById, | |
updateMappedLocation, | |
} from "../shared/sources"; | |
import { | |
compareTimeStampedPoints, | |
isExecutionPointGreaterThan, | |
isExecutionPointWithinRange, | |
} from "../shared/time"; | |
import { Routine, RoutineEvaluationResult } from "../routine"; | |
import { InteractionEventKind } from "./constants"; | |
import { EventCachesCollection } from "./eventDataCaches"; | |
import { FinalInteractionEventInfo, InteractionEventInfo } from "./reactEventListeners"; | |
import { EventCategory } from "../shared/events/eventParsing"; | |
import { Context } from "../../../shared/context"; | |
import { Dictionary } from "lodash"; | |
export interface PointWithEventType extends TimeStampedPoint { | |
kind: "keypress" | "mousedown"; | |
} | |
type EventCategories = "Mouse" | "Keyboard"; | |
export interface EventListenerEntry { | |
eventType: string; | |
categoryKey: EventCategories; | |
} | |
export const EVENTS_FOR_RECORDING_TARGET: Partial< | |
Record<RecordingTarget, Record<InteractionEventKind, EventListenerEntry>> | |
> = { | |
gecko: { | |
mousedown: { categoryKey: "Mouse", eventType: "event.mouse.click" }, | |
keypress: { categoryKey: "Keyboard", eventType: "event.keyboard.keypress" }, | |
}, | |
chromium: { | |
mousedown: { categoryKey: "Mouse", eventType: "click" }, | |
keypress: { categoryKey: "Keyboard", eventType: "keypress" }, | |
}, | |
}; | |
export interface EventWithEntryPoint { | |
event: PointWithEventType; | |
entryPoint: PointDescription; | |
nextEventPoint: TimeStampedPoint; | |
} | |
export type EventListenerProcessResult = | |
| { type: "no_hits" } | |
| { | |
type: "found"; | |
eventListener: EventListenerWithFunctionInfo; | |
} | |
| { type: "not_loaded" }; | |
export type FunctionPreview = { | |
functionName?: string; | |
functionLocation: MappedLocation; | |
functionParameterNames?: string[] | undefined; | |
prototypeId?: string; | |
properties?: Property[]; | |
}; | |
export interface EventListenerWithFunctionInfo { | |
type: string; | |
functionName: string; | |
locationUrl?: string; | |
location: Location; | |
firstBreakablePosition: Location; | |
functionParameterNames: string[]; | |
framework?: string; | |
classComponentName?: string; | |
} | |
export type FormattedEventListener = EventListenerWithFunctionInfo & { | |
sourceDetails?: SourceDetails; | |
}; | |
export type FunctionWithPreview = Omit<ProtocolObject, "preview"> & { | |
preview: FunctionPreview; | |
}; | |
// TS magic: https://stackoverflow.com/a/57837897/62937 | |
type DeepRequired<T, P extends string[]> = T extends object | |
? Omit<T, Extract<keyof T, P[0]>> & | |
Required< | |
{ | |
[K in Extract<keyof T, P[0]>]: NonNullable<DeepRequired<T[K], ShiftUnion<P>>>; | |
} | |
> | |
: T; | |
// Analogues to array.prototype.shift | |
export type Shift<T extends any[]> = ((...t: T) => any) extends ( | |
first: any, | |
...rest: infer Rest | |
) => any | |
? Rest | |
: never; | |
// use a distributed conditional type here | |
type ShiftUnion<T> = T extends any[] ? Shift<T> : never; | |
export type NodeWithPreview = DeepRequired< | |
ProtocolObject, | |
["preview", "node"] | ["preview", "getterValues"] | |
>; | |
export const isFunctionPreview = (obj?: ObjectPreview): obj is FunctionPreview => { | |
return !!obj && "functionName" in obj && "functionLocation" in obj; | |
}; | |
export const isFunctionWithPreview = ( | |
obj: ProtocolObject | |
): obj is FunctionWithPreview => { | |
return ( | |
(obj.className === "Function" || obj.className === "AsyncFunction") && | |
isFunctionPreview(obj.preview) | |
); | |
}; | |
// For the initial "find the first hit" checks, | |
// we _don't_ want to exclude `react-dom`, because we | |
// need to run the evaluation to see if there's a potential | |
// prop that ran. | |
export const USER_INTERACTION_IGNORABLE_URLS = [ | |
// _Never_ treat Cypress events as user interactions | |
"__cypress/runner/", | |
]; | |
// However, for final "found this source" checks, we _do_ | |
// want to exclude React, CodeSandbox, etc | |
export const IGNORABLE_PARTIAL_SOURCE_URLS = [ | |
// Don't jump into React internals | |
"react-dom", | |
// or CodeSandbox | |
"webpack:///src/sandbox/", | |
"webpack:///sandpack-core/", | |
"webpack:////home/circleci/codesandbox-client", | |
// or Cypress | |
"__cypress/runner/", | |
]; | |
const reIsJsSourceFile = /(js|ts)x?(\?[\w\d]+)*$/; | |
export function shouldIgnoreEventFromSource( | |
sourceDetails?: SourceDetails, | |
ignorableURLS = IGNORABLE_PARTIAL_SOURCE_URLS | |
) { | |
const url = sourceDetails?.url ?? ""; | |
// Ignore sources that aren't displayed in the sources tree, | |
// such as index sources, Playwright-injected internal logic, etc. | |
if (sourceDetails?.kind === "scriptSource" && !reIsJsSourceFile.test(url)) { | |
return true; | |
} | |
return ignorableURLS.some(partialUrl => url.includes(partialUrl)); | |
} | |
export async function findEntryPointsForEventType( | |
eventCachesCollection: EventCachesCollection, | |
replayClient: ReplayClientInterface, | |
eventType: InteractionEventKind, | |
recordingTarget: RecordingTarget, | |
eventCounts: EventCategory[], | |
sessionEndpoint: TimeStampedPoint | |
) { | |
const initialEventType = EVENTS_FOR_RECORDING_TARGET[recordingTarget]?.[eventType]; | |
if (!initialEventType) { | |
return []; | |
} | |
const eventTypesToQuery: string[] = []; | |
if (recordingTarget === "gecko") { | |
// For Firefox, we can use that event string as-is | |
eventTypesToQuery.push(initialEventType.eventType); | |
} else if (recordingTarget === "chromium") { | |
// Now we get to do this the hard way. | |
// Chromium sends back a bunch of different types of events. | |
// For example, a "click" event could actually be `"click,DIV"`, | |
// `"click,BUTTON"`, `"click,BODY"`, etc. | |
// We apparently need to add _all_ of those to this analysis for it to work. | |
const categoryEntry = eventCounts.find( | |
e => e.category === initialEventType.categoryKey | |
); | |
if (!categoryEntry) { | |
return []; | |
} | |
const eventsForType = categoryEntry.events.find( | |
e => e.label === initialEventType.eventType | |
); | |
if (!eventsForType) { | |
return []; | |
} | |
// If there were no hits, this array won't exist | |
const { rawEventTypes = [] } = eventsForType; | |
eventTypesToQuery.push(...rawEventTypes); | |
} | |
// Read all events of these types from the entire recording. | |
const entryPoints = await eventCachesCollection.eventPointsCache.readAsync( | |
"0", | |
sessionEndpoint.point, | |
replayClient, | |
eventTypesToQuery | |
); | |
return entryPoints; | |
} | |
export function findEventsWithMatchingEntryPoints( | |
events: PointWithEventType[], | |
entryPoints: PointDescription[], | |
sourcesById: SourcesById, | |
sessionEndpoint: TimeStampedPoint | |
) { | |
interface EventEntryPointsAndBound { | |
entryPoints: PointDescription[]; | |
nextEventPoint: TimeStampedPoint; | |
} | |
const entryPointsByEvent: Map<PointWithEventType, EventEntryPointsAndBound> = new Map(); | |
// We can group any event listener entry hits based on | |
// the point of the event that triggered them and the | |
// next event of the same type. In other words, | |
// given click events A and B, any click event listener | |
// hits between A and B should have been triggered by A. | |
// This should be much more consistent than doing timestamp | |
// comparisons, which could be incorrect given the | |
// inconsistencies in our backend timestamps. | |
let currentEntryPointIndex = 0; | |
for (const [index, event] of events.entries()) { | |
const nextEventPoint: TimeStampedPoint = events[index + 1] ?? sessionEndpoint; | |
const entryPointsForThisEvent: PointDescription[] = []; | |
// Pull entry point hits off the list until we hit the next event, | |
// by carefully iterating over it as a pseudo-queue. | |
for (; currentEntryPointIndex < entryPoints.length; currentEntryPointIndex++) { | |
const entryPoint = entryPoints[currentEntryPointIndex]; | |
if (isExecutionPointGreaterThan(entryPoint.point, nextEventPoint.point)) { | |
break; | |
} | |
entryPointsForThisEvent.push(entryPoint); | |
} | |
entryPointsByEvent.set(event, { | |
entryPoints: entryPointsForThisEvent, | |
nextEventPoint, | |
}); | |
} | |
const eventsWithMatchingEntryPoints: EventWithEntryPoint[] = []; | |
for (const [event, eventRelations] of entryPointsByEvent.entries()) { | |
// Now that we have all the entry points for this event, | |
// we need to find the first one that's actually useful. | |
const firstSuitableHandledEvent = eventRelations.entryPoints.find(ep => { | |
if (ep.frame?.length) { | |
const preferredLocation = getPreferredLocation(sourcesById, ep.frame); | |
const matchingSource = sourcesById[preferredLocation!.sourceId]; | |
// Find the first event that seems useful to jump to | |
const shouldIgnore = shouldIgnoreEventFromSource( | |
matchingSource, | |
USER_INTERACTION_IGNORABLE_URLS | |
); | |
return !shouldIgnore; | |
} | |
}); | |
if (firstSuitableHandledEvent) { | |
// Assuming we actually found a useful entry point, | |
// add these to the list for further analysis. | |
eventsWithMatchingEntryPoints.push({ | |
event, | |
entryPoint: firstSuitableHandledEvent, | |
nextEventPoint: eventRelations.nextEventPoint, | |
}); | |
} | |
} | |
return eventsWithMatchingEntryPoints; | |
} | |
export const formatEventListener = async ( | |
replayClient: ReplayClientInterface, | |
type: InteractionEventKind, | |
fnPreview: FunctionPreview, | |
sourcesById: SourcesById, | |
cachesCollection: CachesCollection, | |
framework?: string | |
): Promise<FormattedEventListener | undefined> => { | |
const { functionLocation } = fnPreview; | |
updateMappedLocation(sourcesById, functionLocation); | |
const location = getPreferredLocation(sourcesById, functionLocation); | |
if (!location) { | |
return; | |
} | |
const sourceDetails = sourcesById[location.sourceId]; | |
if (!sourceDetails) { | |
return; | |
} | |
const locationUrl = sourceDetails.url; | |
// See if we can get any better details from the parsed source outline | |
const symbols = await cachesCollection.sourceOutlineCache.readAsync( | |
replayClient, | |
location.sourceId | |
); | |
const functionOutline = findFunctionOutlineForLocation(location, symbols); | |
if (!functionOutline) { | |
return; | |
} | |
if (!functionOutline?.breakpointLocation) { | |
// The backend _should_ give us a breakpoint location for this function. | |
// But, I'm seeing some cases where it doesn't. | |
// Figure that out for ourselves the hard way so we can use this | |
// found function and continue processing. | |
const [ | |
, | |
breakablePositionsByLine, | |
] = await cachesCollection.breakpointPositionsCache.readAsync( | |
replayClient, | |
location.sourceId | |
); | |
// Use the function outline to find the first breakable position inside | |
const nextBreakablePosition = findFirstBreakablePositionForFunction( | |
location.sourceId, | |
functionOutline, | |
breakablePositionsByLine | |
); | |
if (!nextBreakablePosition) { | |
return; | |
} | |
functionOutline.breakpointLocation = nextBreakablePosition; | |
} | |
const functionName = functionOutline.name!; | |
const functionParameterNames = functionOutline.parameters; | |
const possibleMatchingClassDefinition = findClassOutlineForLocation(location, symbols); | |
return { | |
type, | |
sourceDetails, | |
location, | |
locationUrl, | |
firstBreakablePosition: { | |
sourceId: sourceDetails?.id, | |
...functionOutline.breakpointLocation, | |
}, | |
functionName: functionName || "Anonymous", | |
functionParameterNames, | |
framework, | |
classComponentName: possibleMatchingClassDefinition?.name, | |
}; | |
}; | |
export function findFirstBreakablePositionForFunction( | |
sourceId: string, | |
functionOutline: FunctionOutline, | |
breakablePositionsByLine: Map<number, SameLineSourceLocations> | |
) { | |
const nearestLines: SameLineSourceLocations[] = []; | |
const { begin, end } = functionOutline.location; | |
for (let lineToCheck = begin.line; lineToCheck <= end.line; lineToCheck++) { | |
const linePositions = breakablePositionsByLine.get(lineToCheck); | |
if (linePositions) { | |
nearestLines.push(linePositions); | |
} | |
} | |
const positionsAsLocations: Location[] = nearestLines.flatMap(line => { | |
return line.columns.map(column => { | |
return { | |
sourceId, | |
line: line.line, | |
column, | |
}; | |
}); | |
}); | |
// We _hope_ that the first breakable position _after_ this function declaration is the first | |
// position _inside_ the function itself, either a later column on the same line or on the next line. | |
const nextBreakablePosition = positionsAsLocations.find( | |
p => isLocationBefore(begin, p) && isLocationBefore(p, end) | |
); | |
return nextBreakablePosition; | |
} | |
export function findFunctionOutlineForLocation( | |
location: SourceLocation, | |
sourceOutline: getSourceOutlineResult | |
): FunctionOutline | undefined { | |
let foundFunctionOutline: FunctionOutline | undefined = undefined; | |
let foundFunctionBegin: SourceLocation | undefined; | |
for (const functionOutline of sourceOutline.functions) { | |
const functionBegin = functionOutline.location.begin; | |
const functionEnd = functionOutline.location.end; | |
const functionIsBeforeLocation = isLocationBefore(functionBegin, location); | |
const locationIsBeforeEnd = isLocationBefore(location, functionEnd); | |
const functionIsCloserThanFoundFunction = | |
!foundFunctionBegin || isLocationBefore(foundFunctionBegin, functionBegin); | |
const isMatch = | |
functionIsBeforeLocation && | |
locationIsBeforeEnd && | |
functionIsCloserThanFoundFunction; | |
if (isMatch) { | |
foundFunctionBegin = functionBegin; | |
foundFunctionOutline = functionOutline; | |
} | |
} | |
return foundFunctionOutline; | |
} | |
export function findClassOutlineForLocation( | |
location: SourceLocation, | |
sourceOutline: getSourceOutlineResult | |
): ClassOutline | undefined { | |
let foundClassOutline: ClassOutline | undefined = undefined; | |
let foundClassBegin: SourceLocation | undefined; | |
for (const classOutline of sourceOutline.classes) { | |
const classBegin = classOutline.location.begin; | |
const classEnd = classOutline.location.end; | |
const functionIsBeforeLocation = isLocationBefore(classBegin, location); | |
const locationIsBeforeEnd = isLocationBefore(location, classEnd); | |
const functionIsCloserThanFoundFunction = | |
!foundClassBegin || isLocationBefore(foundClassBegin, classBegin); | |
const isMatch = | |
functionIsBeforeLocation && | |
locationIsBeforeEnd && | |
functionIsCloserThanFoundFunction; | |
if (isMatch) { | |
foundClassBegin = classBegin; | |
foundClassOutline = classOutline; | |
} | |
} | |
return foundClassOutline; | |
} | |
export async function processEventListenerLocation( | |
replayClient: ReplayClient, | |
cachesCollection: CachesCollection, | |
evalResult: RoutineEvaluationResult, | |
eventsByEntryPointPoint: Record<string, EventWithEntryPoint>, | |
sourcesById: SourcesById, | |
eventType: InteractionEventKind | |
): Promise<EventListenerProcessResult> { | |
const { point, value, data } = evalResult; | |
// The backend _currently_ uses an identical fake pause ID. | |
// Just use the point string instead for now. | |
cachesCollection.cachePauseData(replayClient, evalResult.pauseId, data); | |
const obj = cachesCollection.getCachedObject(evalResult.pauseId, value.object!); | |
let functionPreview: FunctionPreview | undefined = undefined; | |
let framework: string | undefined = undefined; | |
// The evaluation _may_ return `{handlerProp, fieldName}` if | |
// it found a likely React event handler prop. | |
// If no event was found at all, we'd have no object | |
// and skip over these checks entirely. | |
const handlerProp = obj?.preview?.properties?.find(p => p.name === "handlerProp"); | |
const fieldName = obj?.preview?.properties?.find(p => p.name === "fieldName"); | |
if (handlerProp && fieldName) { | |
// If it did find a React prop function, get its | |
// preview and format it so we know the preferred location. | |
const functionInstanceDetails = cachesCollection.getCachedObject( | |
evalResult.pauseId, | |
handlerProp.object! | |
); | |
if (functionInstanceDetails && isFunctionPreview(functionInstanceDetails.preview)) { | |
functionPreview = functionInstanceDetails.preview; | |
framework = "react"; | |
} | |
} | |
if (!functionPreview) { | |
const entryPoint = eventsByEntryPointPoint[point]; | |
if (entryPoint.entryPoint.frame) { | |
// Otherwise, use the location from the actual JS event handler. | |
functionPreview = { | |
functionLocation: entryPoint.entryPoint.frame, | |
}; | |
} | |
} | |
if (!functionPreview) { | |
return { type: "no_hits" }; | |
} | |
let formattedEventListener: | |
| FormattedEventListener | |
| undefined = await formatEventListener( | |
replayClient, | |
eventType, | |
functionPreview, | |
sourcesById, | |
cachesCollection, | |
framework | |
); | |
if (!formattedEventListener) { | |
return { type: "no_hits" }; | |
} | |
const { sourceDetails } = formattedEventListener; | |
if (shouldIgnoreEventFromSource(sourceDetails)) { | |
// Intentionally _don't_ jump to into specific ignorable libraries, like React | |
formattedEventListener = undefined; | |
} | |
let result: EventListenerProcessResult = { type: "no_hits" }; | |
if (formattedEventListener) { | |
// omit the `sourceDetails` field, no need to persist that | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
const { sourceDetails, ...eventListener } = formattedEventListener; | |
result = { | |
type: "found", | |
eventListener, | |
}; | |
} | |
return result; | |
} | |
function locationToString(location: Location) { | |
return `${location.sourceId}:${location.line}:${location.column}`; | |
} | |
export async function filterResultsByValidHits( | |
replayClient: ReplayClientInterface, | |
allProcessedResults: InteractionEventInfo[], | |
sourcesById: SourcesById | |
): Promise<FinalInteractionEventInfo[]> { | |
// We know the list of functions that we think were used. | |
// JS event listeners were _definitely_ hit, because those | |
// were pulled directly from the entry point stack frames. | |
// But, our heuristics around React props _might_ have produced | |
// some false positives. We can confirm those by getting hit points | |
// for each of those functions and seeing if they actually got hit | |
// during the event's time range. | |
const locationsToSearch: Map<string, Location> = new Map(); | |
// Get a unique set of locations to search for hits, | |
// based on the first breakable position of each function. | |
for (const processedResult of allProcessedResults) { | |
const { processResult: result } = processedResult; | |
if (result.type === "found") { | |
const { firstBreakablePosition } = result.eventListener; | |
const source = sourcesById[firstBreakablePosition.sourceId]!; | |
// We need to search for hit points for _all_ corresponding sources, | |
// especially since the file may have many duplicates due to multiple tests | |
// being loaded in the same recording. | |
for (const correspondingSourceId of source.correspondingSourceIds) { | |
const correspondingLocation = { | |
...firstBreakablePosition, | |
sourceId: correspondingSourceId, | |
}; | |
const locationString = locationToString(correspondingLocation); | |
if (!locationsToSearch.has(locationString)) { | |
locationsToSearch.set(locationString, correspondingLocation); | |
} | |
} | |
} | |
} | |
const allLocationHits = await replayClient.findPoints({ | |
kind: "locations", | |
locations: [...locationsToSearch.values()], | |
}); | |
allLocationHits.sort(compareTimeStampedPoints); | |
allLocationHits.forEach(p => { | |
updateMappedLocation(sourcesById, p.frame); | |
}); | |
const locationHitsByLocationString: Dictionary< | |
PointDescription[] | undefined | |
> = groupBy(allLocationHits, hit => { | |
const preferredLocation = getPreferredLocation(sourcesById, hit.frame); | |
const locationString = locationToString(preferredLocation!); | |
return locationString; | |
}); | |
const finalResultsWithValidHits: FinalInteractionEventInfo[] = []; | |
for (const result of allProcessedResults) { | |
// We can ignore any results that didn't find a function. | |
if (result.processResult.type !== "found") { | |
continue; | |
} | |
const { firstBreakablePosition } = result.processResult.eventListener; | |
const source = sourcesById[firstBreakablePosition.sourceId]!; | |
let hitInEventTimeRange: PointDescription | undefined = undefined; | |
for (const correspondingSourceId of source.correspondingSourceIds) { | |
const locationString = locationToString({ | |
...firstBreakablePosition, | |
sourceId: correspondingSourceId, | |
}); | |
const hitsForLocation = locationHitsByLocationString[locationString]; | |
hitInEventTimeRange = hitsForLocation?.find(hit => { | |
// Check to see if this function really did run during the event's time range. | |
return isExecutionPointWithinRange( | |
hit.point, | |
result.point, | |
result.nextEventPoint.point | |
); | |
}); | |
if (hitInEventTimeRange) { | |
break; | |
} | |
} | |
if (!hitInEventTimeRange) { | |
continue; | |
} | |
// We now know that the function we found _was_ hit during the event's time range. | |
finalResultsWithValidHits.push({ | |
// The main timestamp is the point of the actual click/keypress event | |
point: result.point, | |
time: result.time, | |
eventKind: result.eventKind, | |
// We save the point when the found function itself ran, | |
// so the UI can jump to that time | |
listenerPoint: { | |
// Drop the stack frame info so we just have the TimeStampedPoint | |
point: hitInEventTimeRange.point, | |
time: hitInEventTimeRange.time, | |
}, | |
// We store the function details so we know the source location | |
// and the function name | |
eventListener: result.processResult.eventListener, | |
// And we tack on the timestamp of the next event of the same kind | |
// so that we can double-check ranges if necessary. | |
nextEventPoint: result.nextEventPoint, | |
}); | |
} | |
return finalResultsWithValidHits; | |
} | |
export async function deriveFakeSessionEventsFromEntryPoints( | |
eventType: InteractionEventKind, | |
entryPoints: PointDescription[], | |
sourcesById: SourcesById, | |
canUseSharedProcesses: boolean, | |
routine: Routine, | |
cx: Context | |
): Promise<PointWithEventType[]> { | |
// Look up source details for each entry point. | |
const entryPointsWithLocations = entryPoints.map(ep => { | |
const preferredLocation = getPreferredLocation(sourcesById, ep.frame); | |
const matchingSource = sourcesById[preferredLocation!.sourceId]!; | |
return { | |
point: ep.point, | |
time: ep.time, | |
location: preferredLocation, | |
source: matchingSource, | |
}; | |
}); | |
// We can ignore any entry points that are from Cypress. | |
const nonCypressEntryPoints = entryPointsWithLocations.filter(ep => { | |
const shouldIgnore = shouldIgnoreEventFromSource( | |
ep.source, | |
USER_INTERACTION_IGNORABLE_URLS | |
); | |
return !shouldIgnore; | |
}); | |
const parsedEvents: (PointWithEventType & { eventTimestamp: number })[] = []; | |
// Browsers save the current event object as `window.event`. | |
// Events have an `e.timeStamp` field. | |
// We can use that to group together all entry point hits that | |
// ran in response to the same event. | |
// Note that the `e.timeStamp` field will _not_ match up with the | |
// `time` fields we have from our various evaluations, as the in-page | |
// execution could be very different from the recording playback | |
// (reloads, etc). | |
await routine.runEvaluation( | |
{ | |
points: nonCypressEntryPoints.map(p => p.point), | |
expression: ` | |
(() => { | |
return window.event ? JSON.stringify({ | |
type: window.event.type, | |
timeStamp: window.event.timeStamp | |
}) : null; | |
})() | |
`, | |
// Evaluate in the top frame. | |
// This really seems to make a difference in whether`window.event` is available? | |
frameIndex: 0, | |
shareProcesses: canUseSharedProcesses, | |
onResult: result => { | |
// `onResult` needs to be synchronous - just save the results for later async processing | |
const parsedValue = JSON.parse(result.value.value ?? "null"); | |
if (parsedValue) { | |
parsedEvents.push({ | |
kind: eventType, | |
point: result.point, | |
time: result.time, | |
eventTimestamp: parsedValue.timeStamp, | |
}); | |
} | |
}, | |
}, | |
cx | |
); | |
parsedEvents.sort(compareTimeStampedPoints); | |
const eventsByEventTimestamp = groupBy(parsedEvents, p => p.eventTimestamp); | |
// Synthesize fake session events by reusing the point and time | |
// of the first entry point that ran in response to each event. | |
const finalEvents: PointWithEventType[] = Object.values(eventsByEventTimestamp).map( | |
e => { | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
const { eventTimestamp, ...finalEvent } = e[0]; | |
return finalEvent; | |
} | |
); | |
// Have to ensure these are sorted for proper diffing/grouping later | |
finalEvents.sort(compareTimeStampedPoints); | |
return finalEvents; | |
} |
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. */ | |
// The main React Event Listeners routine implementation | |
import { | |
Annotation, | |
MouseEvent as ReplayMouseEvent, | |
KeyboardEvent as ReplayKeyboardEvent, | |
TimeStampedPoint, | |
} from "@replayio/protocol"; | |
import { Context } from "../../../shared/context"; | |
import { Routine, RoutineEvaluationResult, RoutineSpec } from "../routine"; | |
import { parseBuildIdComponents } from "../../../shared/utils"; | |
import { createProtocolClient } from "../shared/protocolClient"; | |
import { compareTimeStampedPoints } from "../shared/time"; | |
import { ReplayClient } from "../shared/replayClient"; | |
import { createCaches } from "../shared/caches/ProtocolDataCaches"; | |
import { buildDateStringToDate } from "../../../shared/linkerVersion"; | |
import { createEventCaches } from "./eventDataCaches"; | |
import { | |
PointWithEventType, | |
processEventListenerLocation, | |
EventListenerProcessResult, | |
findEventsWithMatchingEntryPoints, | |
findEntryPointsForEventType, | |
EventWithEntryPoint, | |
EventListenerWithFunctionInfo, | |
filterResultsByValidHits, | |
deriveFakeSessionEventsFromEntryPoints, | |
} from "./eventListenerProcessing"; | |
import { InteractionEventKind } from "./constants"; | |
import { createReactEventMapper } from "./evaluationMappers"; | |
/** All results from processing the events, including "no hits" */ | |
export interface InteractionEventInfo extends TimeStampedPoint { | |
nextEventPoint: TimeStampedPoint; | |
eventKind: InteractionEventKind; | |
processResult: EventListenerProcessResult; | |
} | |
/** Final results, narrowed down to _only_ events with hits */ | |
export interface EventListenerJumpLocationContents { | |
listenerPoint: TimeStampedPoint; | |
nextEventPoint: TimeStampedPoint; | |
eventKind: InteractionEventKind; | |
eventListener: EventListenerWithFunctionInfo; | |
} | |
export type FinalInteractionEventInfo = EventListenerJumpLocationContents & | |
TimeStampedPoint; | |
async function runReactEventListenersRoutine(routine: Routine, cx: Context) { | |
const protocolClient = createProtocolClient(routine.iface, cx); | |
const replayClient = new ReplayClient(protocolClient); | |
const cachesCollection = createCaches(); | |
const eventCachesCollection = createEventCaches(cachesCollection); | |
const mouseEvents: ReplayMouseEvent[] = []; | |
const keyboardEvents: ReplayKeyboardEvent[] = []; | |
protocolClient.Session.addMouseEventsListener(entry => { | |
mouseEvents.push(...entry.events); | |
}); | |
protocolClient.Session.addKeyboardEventsListener(entry => { | |
keyboardEvents.push(...entry.events); | |
}); | |
// We need to load the sources and events before we can do anything else. | |
// Might as well load those all in parallel | |
const [ | |
eventCounts, | |
sourcesById, | |
recordingTarget, | |
sessionEndpoint, | |
buildId, | |
] = await Promise.all([ | |
eventCachesCollection.eventCountsCache.readAsync(replayClient, null), | |
cachesCollection.sourcesByIdCache.readAsync(replayClient), | |
cachesCollection.recordingTargetCache.readAsync(replayClient), | |
replayClient.getSessionEndpoint(replayClient.getSessionId()!), | |
replayClient.getBuildId(), | |
protocolClient.Session.findMouseEvents({}), | |
protocolClient.Session.findKeyboardEvents({}), | |
]); | |
const buildMetadata = parseBuildIdComponents(buildId)!; | |
const { runtime, date: dateString } = buildMetadata; | |
const canUseSharedProcesses = canUseSharedProcessesForEvaluations(runtime, dateString); | |
// We only care about click ("mousedown") and keypress events | |
const clickEvents = mouseEvents.filter( | |
event => event.kind === "mousedown" | |
) as PointWithEventType[]; | |
const keypressEvents = keyboardEvents.filter( | |
event => event.kind === "keypress" | |
) as PointWithEventType[]; | |
const searchableEventTypes: InteractionEventKind[] = ["mousedown", "keypress"]; | |
const eventsForSearchableEventTypes: Record< | |
InteractionEventKind, | |
PointWithEventType[] | |
> = { | |
mousedown: clickEvents, | |
keypress: keypressEvents, | |
}; | |
const clickAndKeypressResults = await Promise.all( | |
searchableEventTypes.map(async eventType => { | |
const sessionEvents = eventsForSearchableEventTypes[eventType]; | |
// This is a relatively cheap call to `Session.findPoints` to find | |
// all JS event listener calls that match the given event type. | |
const entryPoints = await findEntryPointsForEventType( | |
eventCachesCollection, | |
replayClient, | |
eventType, | |
recordingTarget, | |
eventCounts, | |
sessionEndpoint | |
); | |
let eventsToProcess = sessionEvents; | |
if (sessionEvents.length === 0 && entryPoints.length > 0) { | |
// Our `Session.find*Events` API returned 0 results, but | |
// there _are_ hits of this type in the recording. | |
// This is likely a Cypress test. Cypress fakes events, | |
// so they show up in the recording, but they don't get | |
// handled by the browser's actual input logic. | |
// We can try to derive what the original session events | |
// _would_ have looked like, by looking at the actual JS | |
// event objects. These have `timeStamp` and `type` fields | |
// we can use to determine how many session events we'd have. | |
eventsToProcess = await deriveFakeSessionEventsFromEntryPoints( | |
eventType, | |
entryPoints, | |
sourcesById, | |
canUseSharedProcesses, | |
routine, | |
cx | |
); | |
} | |
// One we have all the entry points, we can correlate them with | |
// the user interaction events based on execution points, | |
// and filter it down to just events that had any listener run. | |
const eventsWithHits = findEventsWithMatchingEntryPoints( | |
eventsToProcess, | |
entryPoints, | |
sourcesById, | |
sessionEndpoint | |
); | |
// The original recorded session events occur before any actual JS runs. | |
// We need to map between the original session points and the | |
// later event listener hit points. | |
const eventsByEntryPointPoint: Record<string, EventWithEntryPoint> = {}; | |
const listenerHitPointsByOriginalEventPoint: Record<string, TimeStampedPoint> = {}; | |
for (const e of eventsWithHits) { | |
eventsByEntryPointPoint[e.entryPoint.point] = e; | |
listenerHitPointsByOriginalEventPoint[e.event.point] = { | |
point: e.event.point, | |
time: e.event.time, | |
}; | |
} | |
const reactPropEventMapperResults: RoutineEvaluationResult[] = []; | |
await routine.runEvaluation( | |
{ | |
points: eventsWithHits.map(e => e.entryPoint.point), | |
expression: createReactEventMapper(eventType), | |
// Evaluate in the top frame | |
frameIndex: 0, | |
// Run the eval faster if the runtime supports it | |
shareProcesses: canUseSharedProcesses, | |
// Include nested object previews as part of the pause data, | |
// so that we can synchronously use those during processing | |
// without needing to do further API calls at a given pause. | |
fullReturnedPropertyPreview: true, | |
onResult: result => { | |
// `onResult` needs to be synchronous - just save the results for later async processing | |
reactPropEventMapperResults.push(result); | |
}, | |
}, | |
cx | |
); | |
// Once we have the React prop evaluation results for each of the event | |
// listener hit points, we can process them in parallel. | |
const processedResults: InteractionEventInfo[] = await Promise.all( | |
reactPropEventMapperResults.map(async evalResult => { | |
// We either have a hit with formatted listener function details, or no hit | |
const processResult = await processEventListenerLocation( | |
replayClient, | |
cachesCollection, | |
evalResult, | |
eventsByEntryPointPoint, | |
sourcesById, | |
eventType | |
); | |
const eventWithEntryPoint = eventsByEntryPointPoint[evalResult.point]; | |
return { | |
// Differentiate between the `Session.find*Events" point... | |
point: eventWithEntryPoint.event.point, | |
time: eventWithEntryPoint.event.time, | |
// And the time that the _next_ event of this type occurs. | |
// Note that we don't know for sure the time the | |
// listener itself ran - that will be determined next. | |
nextEventPoint: eventWithEntryPoint.nextEventPoint, | |
eventKind: eventType, | |
processResult, | |
}; | |
}) | |
); | |
return processedResults; | |
}) | |
); | |
const allProcessedResults = clickAndKeypressResults | |
.flat() | |
.sort(compareTimeStampedPoints); | |
// We only need to save annotations for points that had an actual | |
// listener hit. The UI can assume that there are no hits otherwise. | |
const finalResultsWithValidHits = await filterResultsByValidHits( | |
replayClient, | |
allProcessedResults, | |
sourcesById | |
); | |
for (const { point, time, ...contents } of finalResultsWithValidHits) { | |
const annotationContents: EventListenerJumpLocationContents = contents; | |
const annotation: Annotation = { | |
point, | |
time, | |
kind: "event-listeners-jump-location", | |
contents: JSON.stringify(annotationContents), | |
}; | |
cx.logger.debug("BackendEventListener", { point, time, ...contents }); | |
routine.addAnnotation(annotation); | |
} | |
} | |
function canUseSharedProcessesForEvaluations(runtime: string, dateString: string) { | |
let canUseSharedProcesses = false; | |
if (runtime === "chromium") { | |
const date = buildDateStringToDate(dateString); | |
// Shared Processes support was added to Chromium in early May | |
const requiredMinBuildDate = new Date("2023-05-10"); | |
canUseSharedProcesses = date >= requiredMinBuildDate; | |
} | |
return canUseSharedProcesses; | |
} | |
export const ReactEventListenersRoutine: RoutineSpec = { | |
name: "ReactEventListeners", | |
version: 2, | |
annotationKinds: ["event-listeners-jump-location"], | |
runRoutine: runReactEventListenersRoutine, | |
shouldRun: buildMetadata => { | |
const { runtime, date: dateString } = buildMetadata; | |
const validRuntime = runtime === "gecko" || runtime === "chromium"; | |
let recordingIsAfterMinBuildDate = true; | |
if (runtime === "chromium") { | |
const date = buildDateStringToDate(dateString); | |
// Chromium Events support was added at the end of March | |
const requiredMinBuildDate = new Date("2023-03-30"); | |
recordingIsAfterMinBuildDate = date >= requiredMinBuildDate; | |
} | |
return validRuntime && recordingIsAfterMinBuildDate; | |
}, | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment