Skip to content

Commit

Permalink
Look up DOM nodes for Cypress "click" steps at the before point (#10556)
Browse files Browse the repository at this point in the history
* Look up DOM nodes for Cypress "click" steps at the before point

* Simplify Cypress cache per code review
  • Loading branch information
markerikson authored Jun 11, 2024
1 parent 96a68a9 commit 2576ce8
Show file tree
Hide file tree
Showing 2 changed files with 231 additions and 5 deletions.
131 changes: 126 additions & 5 deletions src/ui/components/TestSuite/suspense/TestEventDetailsCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<Panel>` to fetch all of the step details and DOM node data for a single test.
// `<Panel>` will kick this off when it renders, and then the cache will fetch all of the data in the background.
export const testEventDetailsIntervalCache = createFocusIntervalCacheForExecutionPoints<
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions src/ui/components/TestSuite/utils/cypressStepDomNodes.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 2576ce8

Please sign in to comment.