Skip to content

Instantly share code, notes, and snippets.

@jasonLaster
Last active July 26, 2023 19:20
Show Gist options
  • Save jasonLaster/3062d8ab6e4aead8c66ecb7cecb8dc33 to your computer and use it in GitHub Desktop.
Save jasonLaster/3062d8ab6e4aead8c66ecb7cecb8dc33 to your computer and use it in GitHub Desktop.
Jump to Code
/* 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;
}
/* 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