Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/react-grab/e2e/element-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,80 @@ test.describe("Element Context Fallback", () => {
expect(clipboard).toContain("long-dom-element");
expect(clipboard.length).toBeLessThanOrEqual(510);
});

test("should include descendant text for syntax highlighted code blocks", async ({
reactGrab,
}) => {
await reactGrab.page.evaluate(() => {
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "fixed",
top: "200px",
left: "200px",
width: "600px",
height: "160px",
zIndex: "999",
});

const codeBlock = document.createElement("pre");
codeBlock.className = "shiki shiki-themes github-light github-dark";
codeBlock.tabIndex = 0;
codeBlock.innerHTML = `
<code>
<span class="line"><span>git</span><span> add</span><span> .github/workflows/react-doctor.yml</span></span>
<span class="line"><span>git</span><span> commit</span><span> -m</span><span> "Add React Doctor to CI"</span></span>
<span class="line"><span>git</span><span> push</span></span>
</code>
`;

wrapper.appendChild(codeBlock);
document.body.appendChild(wrapper);
});

const didCopy = await reactGrab.copyElementViaApi("pre.shiki");
expect(didCopy).toBe(true);

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain("<pre");
expect(clipboard).toContain("git add .github/workflows/react-doctor.yml");
expect(clipboard).toContain('git commit -m "Add React Doctor to CI"');
expect(clipboard).toContain("</pre>");
});

test("should include descendant text for nested link labels", async ({ reactGrab }) => {
await reactGrab.page.evaluate(() => {
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "fixed",
top: "200px",
left: "200px",
width: "320px",
height: "80px",
zIndex: "999",
});

const link = document.createElement("a");
link.href = "/docs/ci-and-prs/github-actions-setup";
link.className = "flex h-8 w-full items-center gap-2 rounded-md px-2";
link.innerHTML = `
<span aria-hidden="true">#</span>
<span><span>GitHub Actions setup</span></span>
`;

wrapper.appendChild(link);
document.body.appendChild(wrapper);
});

const didCopy = await reactGrab.copyElementViaApi(
"a[href='/docs/ci-and-prs/github-actions-setup']",
);
expect(didCopy).toBe(true);

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain('<a href="/docs/ci-and-prs/github-actions-setup"');
expect(clipboard).toContain("GitHub Actions setup");
expect(clipboard).toContain('selector: [href="/docs/ci-and-prs/github-actions-setup"]');
expect(clipboard).toContain("</a>");
});
});
});
1 change: 1 addition & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const LABEL_GAP_PX = 4;
export const PREVIEW_TEXT_MAX_LENGTH = 100;
export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15;
export const PREVIEW_MAX_ATTRS = 3;
export const CONTEXT_SELECTOR_MAX_LENGTH_CHARS = 160;
export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [
"id",
"class",
Expand Down
116 changes: 103 additions & 13 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
PREVIEW_IDENTIFYING_ATTRS,
SYMBOLICATION_TIMEOUT_MS,
DEFAULT_MAX_CONTEXT_LINES,
CONTEXT_SELECTOR_MAX_LENGTH_CHARS,
} from "../constants.js";
import { getTagName } from "../utils/get-tag-name.js";
import { truncateString } from "../utils/truncate-string.js";
Expand All @@ -31,6 +32,7 @@ import { normalizeFilePath } from "../utils/normalize-file-path.js";
import { parsePackageName } from "../utils/parse-package-name.js";
import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js";
import { isInternalAttribute } from "../utils/strip-internal-attributes.js";
import { createElementSelector } from "../utils/create-element-selector.js";
import {
isInternalComponentName,
isUsefulComponentName,
Expand Down Expand Up @@ -378,6 +380,52 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => {
});
};

const isResolvedSourceFrame = (frame: StackFrame): boolean =>
Boolean(frame.fileName && isSourceFile(frame.fileName));

const isTrustedSourcePath = (filePath: string | null | undefined): boolean =>
Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath));

const getSourceComponentNameFromFrame = (frame: StackFrame): string | null =>
frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null;

const getFirstContextFrame = (stack: StackFrame[] | null): StackFrame | null => {
if (!stack) return null;
for (const frame of stack) {
if (parsePackageName(frame.fileName)) return frame;
if (isResolvedSourceFrame(frame)) return frame;
if (frame.isServer && (!frame.functionName || getSourceComponentNameFromFrame(frame))) {
return frame;
}
if (getSourceComponentNameFromFrame(frame)) return frame;
}
return null;
};

const hasTrustedSourceTrace = (
leadingSource: ResolvedSource | null,
stack: StackFrame[] | null,
): boolean =>
isTrustedSourcePath(leadingSource?.filePath) ||
Boolean(stack?.some((frame) => isTrustedSourcePath(frame.fileName)));

const shouldAppendSelectorHint = (
leadingSource: ResolvedSource | null,
stack: StackFrame[] | null,
): boolean => {
if (isTrustedSourcePath(leadingSource?.filePath)) return false;

const firstContextFrame = getFirstContextFrame(stack);
if (!firstContextFrame) return true;
if (parsePackageName(firstContextFrame.fileName)) return true;
return !hasTrustedSourceTrace(leadingSource, stack);
};

const formatSelectorContextLine = (element: Element): string => {
const selector = createElementSelector(element);
return `\n selector: ${truncateString(selector, CONTEXT_SELECTOR_MAX_LENGTH_CHARS)}`;
};

const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => {
if (!isInstrumentationActive()) return [];
const fiber = getFiberFromHostInstance(element);
Expand Down Expand Up @@ -513,9 +561,12 @@ export const getStackContext = async (
const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES;
const leadingSource = await getFiberSource(element);
const stack = await getStack(element);
const selectorContext = shouldAppendSelectorHint(leadingSource, stack)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
? formatSelectorContextLine(element)
: "";

if (stack && hasFormattableFrames(stack)) {
return formatStackContext(stack, options, leadingSource);
return `${formatStackContext(stack, options, leadingSource)}${selectorContext}`;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

if (leadingSource) {
Expand All @@ -524,10 +575,10 @@ export const getStackContext = async (

const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines);
if (componentNames.length > 0) {
return componentNames.map((name) => `\n in ${name}`).join("");
return `${componentNames.map((name) => `\n in ${name}`).join("")}${selectorContext}`;
}

return "";
return selectorContext;
};

export const getElementContext = async (
Expand All @@ -552,8 +603,8 @@ const getFallbackContext = (element: Element): string => {

const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
const directText = getDirectTextContent(element);
const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
const previewText = getPreviewTextContent(element, tagName);
const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH);

if (truncatedText.length > 0) {
return `<${tagName}${attrsText}>\n ${truncatedText}\n</${tagName}>`;
Expand Down Expand Up @@ -591,6 +642,37 @@ const formatPriorityAttrs = (
const isClassOrStyleAttr = (name: string): boolean =>
name === "class" || name === "className" || name === "style";

const TEXT_CONTENT_PREVIEW_TAGS = new Set([
"a",
"abbr",
"b",
"button",
"caption",
"cite",
"code",
"dd",
"dt",
"em",
"figcaption",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"label",
"legend",
"li",
"p",
"pre",
"small",
"span",
"strong",
"summary",
"td",
"th",
]);

const formatAttrsForPreview = (element: Element): string => {
const identifyingParts: string[] = [];
const remainingParts: string[] = [];
Expand All @@ -614,11 +696,13 @@ const formatAttrsForPreview = (element: Element): string => {
return identifyingParts.join("") + remainingParts.join("") + classAttr;
};

const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim();

const getDirectTextContent = (element: Element): string => {
let directText = "";
for (const node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const trimmed = node.textContent?.trim() ?? "";
const trimmed = collapseTextContent(node.textContent ?? "");
if (trimmed) {
directText += (directText ? " " : "") + trimmed;
}
Expand All @@ -627,6 +711,12 @@ const getDirectTextContent = (element: Element): string => {
return directText;
};

const getPreviewTextContent = (element: Element, tagName: string): string => {
const directText = getDirectTextContent(element);
if (directText || !TEXT_CONTENT_PREVIEW_TAGS.has(tagName)) return directText;
return collapseTextContent(element.textContent ?? "");
};

const formatChildElements = (elements: Array<Element>): string => {
if (elements.length === 0) return "";
if (elements.length <= 2) {
Expand All @@ -647,8 +737,8 @@ export const getInlineHTMLPreview = (element: Element): string => {
}

const attrsText = formatAttrsForPreview(element);
const directText = getDirectTextContent(element);
const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
const previewText = getPreviewTextContent(element, tagName);
const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH);

if (truncatedText) {
return `<${tagName}${attrsText}>${truncatedText}</${tagName}>`;
Expand All @@ -659,7 +749,7 @@ export const getInlineHTMLPreview = (element: Element): string => {
export const getHTMLPreview = (element: Element): string => {
const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
const directText = getDirectTextContent(element);
const previewText = getPreviewTextContent(element, tagName);

const topElements: Array<Element> = [];
const bottomElements: Array<Element> = [];
Expand All @@ -682,12 +772,12 @@ export const getHTMLPreview = (element: Element): string => {

let content = "";
const topElementsStr = formatChildElements(topElements);
if (topElementsStr) content += `\n ${topElementsStr}`;
if (directText.length > 0) {
content += `\n ${truncateString(directText, PREVIEW_TEXT_MAX_LENGTH)}`;
if (topElementsStr && !previewText) content += `\n ${topElementsStr}`;
if (previewText.length > 0) {
content += `\n ${truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH)}`;
}
const bottomElementsStr = formatChildElements(bottomElements);
if (bottomElementsStr) content += `\n ${bottomElementsStr}`;
if (bottomElementsStr && !previewText) content += `\n ${bottomElementsStr}`;
Comment thread
cursor[bot] marked this conversation as resolved.

if (content.length > 0) {
return `<${tagName}${attrsText}>${content}\n</${tagName}>`;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-grab/src/utils/create-element-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const PREFERRED_SELECTOR_ATTRIBUTE_NAMES = new Set<string>([
"data-cy",
"data-qa",
"aria-label",
"href",
"src",
"role",
"name",
"title",
Expand Down
Loading