From 2576ce855da65c40df3add2b4b61ca54bc996a9b Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 11 Jun 2024 12:21:20 -0400 Subject: [PATCH] Look up DOM nodes for Cypress "click" steps at the before point (#10556) * Look up DOM nodes for Cypress "click" steps at the before point * Simplify Cypress cache per code review --- .../suspense/TestEventDetailsCache.ts | 131 +++++++++++++++++- .../TestSuite/utils/cypressStepDomNodes.ts | 105 ++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 src/ui/components/TestSuite/utils/cypressStepDomNodes.ts diff --git a/src/ui/components/TestSuite/suspense/TestEventDetailsCache.ts b/src/ui/components/TestSuite/suspense/TestEventDetailsCache.ts index 46016ef3fdf..5aa3a8bc530 100644 --- a/src/ui/components/TestSuite/suspense/TestEventDetailsCache.ts +++ b/src/ui/components/TestSuite/suspense/TestEventDetailsCache.ts @@ -7,12 +7,18 @@ import { TimeStampedPoint, } from "@replayio/protocol"; import cloneDeep from "lodash/cloneDeep"; -import { ExternallyManagedCache, createExternallyManagedCache } from "suspense"; +import { Cache, ExternallyManagedCache, createCache, createExternallyManagedCache } from "suspense"; +import { assert } from "protocol/utils"; import { Element, elementCache } from "replay-next/components/elements/suspense/ElementCache"; import { createFocusIntervalCacheForExecutionPoints } from "replay-next/src/suspense/FocusIntervalCache"; import { objectCache } from "replay-next/src/suspense/ObjectPreviews"; -import { cachePauseData, setPointAndTimeForPauseId } from "replay-next/src/suspense/PauseCache"; +import { pauseEvaluationsCache } from "replay-next/src/suspense/PauseCache"; +import { + cachePauseData, + pauseIdCache, + setPointAndTimeForPauseId, +} from "replay-next/src/suspense/PauseCache"; import { Source, sourcesByIdCache } from "replay-next/src/suspense/SourcesCache"; import { findProtocolObjectProperty, @@ -43,11 +49,19 @@ export type TestEventDomNodeDetails = TimeStampedPoint & { let getPlaywrightTestStepDomNodesString: string | null = null; -async function lazyImportExpression() { +let findDOMNodeObjectIdForPersistentIdExpression: string | null = null; + +async function lazyImportPlaywrightExpression() { const module = await import("../utils/playwrightStepDomNodes"); getPlaywrightTestStepDomNodesString = module.getPlaywrightTestStepDomNodes.toString(); } +async function lazyImportCypressExpression() { + const module = await import("../utils/cypressStepDomNodes"); + findDOMNodeObjectIdForPersistentIdExpression = + module.findDOMNodeObjectIdForPersistentId.toString(); +} + // The interval cache is used by `` to fetch all of the step details and DOM node data for a single test. // `` will kick this off when it renders, and then the cache will fetch all of the data in the background. export const testEventDetailsIntervalCache = createFocusIntervalCacheForExecutionPoints< @@ -171,6 +185,63 @@ export const testEventDomNodeCache: ExternallyManagedCache< }, }); +interface FindDOMNodeObjectIdForPersistentIdResult { + persistentId: string; + domNodeId: string; +} + +const domNodeIdForPersistentIdCache: Cache< + [ + replayClient: ReplayClientInterface, + point: TimeStampedPoint, + persistentIds: (string | number)[] + ], + { pauseId: string; domNodeIds: FindDOMNodeObjectIdForPersistentIdResult[] } +> = createCache({ + debugLabel: "DomNodeIdForPersistentIdCache", + getKey: ([replayClient, point, persistentIds]) => `${point}:${persistentIds.join()}`, + async load([replayClient, point, persistentIds]) { + if (findDOMNodeObjectIdForPersistentIdExpression === null) { + await lazyImportCypressExpression(); + } + + // The runtime expects these to be numbers, so we need to convert them. + const persistentIdsAsNumbers = persistentIds.map(id => Number(id)); + + const expression = ` + const findDOMNodeObjectIdForPersistentId = ${findDOMNodeObjectIdForPersistentIdExpression}; + + const persistentIds = ${JSON.stringify(persistentIdsAsNumbers)}; + + const results = persistentIds.map(persistentId => { + const domNodeId = findDOMNodeObjectIdForPersistentId(persistentId) + return { + persistentId, + domNodeId, + } + }) + + const stringifiedResults = JSON.stringify(results); + + // Implicit return + stringifiedResults; + `; + + const pauseId = await pauseIdCache.readAsync(replayClient, point.point, point.time); + const result = await pauseEvaluationsCache.readAsync(replayClient, pauseId, null, expression); + + const { returned } = result; + assert(typeof returned?.value === "string", "Expected a string to be returned from the eval."); + + const parsedResults = JSON.parse(returned.value) as FindDOMNodeObjectIdForPersistentIdResult[]; + + return { + pauseId, + domNodeIds: parsedResults, + }; + }, +}); + async function fetchCypressStepDetails( replayClient: ReplayClientInterface, filteredEvents: RecordingTestMetadataV3.UserActionEvent[], @@ -346,7 +417,57 @@ async function fetchAndCachePossibleCypressDomNode( } } - await cacheDomNodeEntry(possibleDomNodes, replayClient, pauseId, timeStampedPoint, testEvent); + let finalDomNodeDetails = { + possibleDomNodes, + pauseId, + }; + + if (testEvent.data.command.name === "click") { + // If this is a click event, we need to find the DOM node as it + // existed _before_ the step, as it may have been disconnected + // during the step handling. + const { beforeStep } = testEvent.data.timeStampedPoints; + + if (!beforeStep) { + return; + } + + const persistentIds = possibleDomNodes.map(node => node.persistentId).filter(Boolean); + + const { pauseId: beforeStepPauseId, domNodeIds } = + await domNodeIdForPersistentIdCache.readAsync(replayClient, beforeStep, persistentIds); + + const parsedResultsWithValidNodeIds = domNodeIds.filter(({ domNodeId }) => !!domNodeId); + + if (parsedResultsWithValidNodeIds.length === 0) { + return; + } + + const matchingDomNodes = await Promise.all( + parsedResultsWithValidNodeIds.map(({ domNodeId }) => + objectCache.readAsync(replayClient, beforeStepPauseId, domNodeId, "canOverflow") + ) + ); + + // Note that in this case, we'll have the DOM nodes and pause ID from the before step, + // but we're going to put them in the cache with the result point as the key. + // This is okay! The test step row consistently uses the result point as the key, + // and this _is_ the DOM node and pause ID that relate to the step. + // `highlightNodes()` just needs these values, so it's fine if they're at a different + // point in time than the step itself. + finalDomNodeDetails = { + possibleDomNodes: matchingDomNodes, + pauseId: beforeStepPauseId, + }; + } + + await cacheDomNodeEntry( + finalDomNodeDetails.possibleDomNodes, + replayClient, + finalDomNodeDetails.pauseId, + timeStampedPoint, + testEvent + ); } async function cacheDomNodeEntry( @@ -411,7 +532,7 @@ async function fetchPlaywrightStepDetails( ); if (getPlaywrightTestStepDomNodesString === null) { - await lazyImportExpression(); + await lazyImportPlaywrightExpression(); } // These arguments and the preload eval string correspond to the Playwright setup logic diff --git a/src/ui/components/TestSuite/utils/cypressStepDomNodes.ts b/src/ui/components/TestSuite/utils/cypressStepDomNodes.ts new file mode 100644 index 00000000000..5889876e21d --- /dev/null +++ b/src/ui/components/TestSuite/utils/cypressStepDomNodes.ts @@ -0,0 +1,105 @@ +declare var __RECORD_REPLAY_ARGUMENTS__: { + getPersistentId?: (value: any) => number | null | void; + + internal?: { + registerPlainObject?: (value: any) => string | null | void; + }; +}; + +declare var __RECORD_REPLAY__: { + getObjectFromProtocolId?: (id: string | number) => any; + getProtocolIdForObject?: (object: any) => string | number | null; +}; + +export function findDOMNodeObjectIdForPersistentId(persistentId: number): string | null { + if ( + typeof __RECORD_REPLAY__ === "undefined" || + __RECORD_REPLAY__ == null || + __RECORD_REPLAY__.getProtocolIdForObject == null + ) { + return null; + } + + if ( + typeof __RECORD_REPLAY_ARGUMENTS__ === "undefined" || + __RECORD_REPLAY_ARGUMENTS__ == null || + __RECORD_REPLAY_ARGUMENTS__.getPersistentId == null + ) { + return null; + } + + const { getPersistentId } = __RECORD_REPLAY_ARGUMENTS__; + + let domNodeId: string | null = null; + + function getObjectId(value: any) { + if (typeof __RECORD_REPLAY_ARGUMENTS__ !== "undefined" && __RECORD_REPLAY_ARGUMENTS__ != null) { + if ( + __RECORD_REPLAY_ARGUMENTS__.internal && + __RECORD_REPLAY_ARGUMENTS__.internal.registerPlainObject + ) { + const id = __RECORD_REPLAY_ARGUMENTS__.internal.registerPlainObject(value); + if (id) { + return parseInt(id); + } + } + } + + throw Error("Could not find object id"); + } + + let objectId: string | null = null; + let queue: (ChildNode | Document | Element)[] = [document]; + + // This loop modified from the version in `serialization.ts`, + // which is used to serialize the entire DOM tree. + // In our case, we want to _loop_ over the entire DOM tree, + // but we're only looking to find a single DOM node by persistent ID. + // It's important that we be able to drill down through iframes, + // especially since Cypress tests always run in an iframe. + while (queue.length > 0) { + const domNodeOrText = queue.shift() as Element; + const nodePersistentId = getPersistentId(domNodeOrText); + + if (persistentId === nodePersistentId) { + // Turn this back into a string + objectId = `${getObjectId(domNodeOrText)}`; + break; + } + + let { childNodes, nodeType } = domNodeOrText; + + if (childNodes) { + for (let child of childNodes) { + switch (child.nodeType) { + case Node.COMMENT_NODE: { + break; + } + case Node.TEXT_NODE: { + if (child.textContent?.trim()) { + queue.push(child); + } + break; + } + default: { + queue.push(child); + break; + } + } + } + } + + switch (nodeType) { + case Node.ELEMENT_NODE: { + if (domNodeOrText instanceof HTMLIFrameElement) { + if (domNodeOrText.contentDocument != null) { + queue.push(domNodeOrText.contentDocument); + } + } + break; + } + } + } + + return objectId; +}