diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index a955c9427..3a77e223a 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -139,5 +139,141 @@ 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 = ` + + git add .github/workflows/react-doctor.yml + git commit -m "Add React Doctor to CI" + git push + + `; + + 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(""); + }); + + 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 = ` + + GitHub Actions setup + `; + + 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('"); + }); + + test("should skip preview text for hidden selected roots", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + position: "fixed", + top: "200px", + left: "200px", + width: "200px", + height: "80px", + zIndex: "999", + }); + + const hiddenLabel = document.createElement("span"); + hiddenLabel.setAttribute("aria-hidden", "true"); + hiddenLabel.setAttribute("data-testid", "decorative-hidden-label"); + hiddenLabel.textContent = "Decorative Hidden Label"; + + wrapper.appendChild(hiddenLabel); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi("[data-testid='decorative-hidden-label']"); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('aria-hidden="true"'); + expect(clipboard).not.toContain("Decorative Hidden Label"); + }); + + test("should include nested text for mixed inline content", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + position: "fixed", + top: "200px", + left: "200px", + width: "260px", + height: "80px", + zIndex: "999", + }); + + const link = document.createElement("a"); + link.href = "/docs/mixed-content"; + link.textContent = "Read "; + const emphasizedText = document.createElement("em"); + emphasizedText.textContent = "the docs"; + link.appendChild(emphasizedText); + + wrapper.appendChild(link); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi("a[href='/docs/mixed-content']"); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain("Read the docs"); + expect(clipboard).not.toContain(""); + }); }); }); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 850a446c4..015d86fbc 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -31,6 +31,8 @@ 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 { getPreviewTextContent } from "../utils/get-preview-text-content.js"; import { isInternalComponentName, isUsefulComponentName, @@ -366,6 +368,11 @@ interface StackContextOptions { maxLines?: number; } +interface TraceContextResult { + text: string; + shouldAppendSelectorHint: boolean; +} + const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { if (!stack) return false; return stack.some((frame) => { @@ -378,6 +385,14 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { }); }; +const isTrustedSourcePath = (filePath: string | null | undefined): boolean => + Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath)); + +const formatSelectorContextLine = (element: Element): string => { + const selector = createElementSelector(element); + return `\n selector: ${selector}`; +}; + const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { if (!isInstrumentationActive()) return []; const fiber = getFiberFromHostInstance(element); @@ -435,22 +450,38 @@ const formatStackContext = ( stack: StackFrame[], options: StackContextOptions = {}, leadingSource: ResolvedSource | null = null, -): string => { +): TraceContextResult => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; const isNextProject = isNextProjectRuntime(); const lines: string[] = []; let previousLibraryPackage: string | null = null; let didDedupeLeadingComponent = false; - - if (leadingSource) { - lines.push(formatSourceContextLine(leadingSource, isNextProject)); - } - - const emit = (line: string, libraryPackage: string | null) => { + let hasTrustedSource = false; + let startsWithLowSignalContext = false; + + const emit = ( + line: string, + libraryPackage: string | null, + options: { isTrustedSource?: boolean; isLowSignal?: boolean } = {}, + ) => { + if (lines.length === 0 && options.isLowSignal) { + startsWithLowSignalContext = true; + } + if (options.isTrustedSource) { + hasTrustedSource = true; + } lines.push(line); previousLibraryPackage = libraryPackage; }; + if (leadingSource) { + const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); + emit(formatSourceContextLine(leadingSource, isNextProject), null, { + isTrustedSource, + isLowSignal: !isTrustedSource, + }); + } + for (const frame of stack) { if (lines.length >= maxLines) break; @@ -475,7 +506,9 @@ const formatStackContext = ( if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; - emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage); + emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage, { + isLowSignal: true, + }); continue; } @@ -483,11 +516,13 @@ const formatStackContext = ( emit( libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, libraryPackage, + { isLowSignal: Boolean(libraryPackage) }, ); continue; } if (resolvedSource) { + const isTrustedSource = isTrustedSourcePath(resolvedSource); emit( formatSourceContextLine( { @@ -499,17 +534,24 @@ const formatStackContext = ( isNextProject, ), null, + { + isTrustedSource, + isLowSignal: !isTrustedSource, + }, ); } } - return lines.join(""); + return { + text: lines.join(""), + shouldAppendSelectorHint: startsWithLowSignalContext || !hasTrustedSource, + }; }; -export const getStackContext = async ( +const getTraceContext = async ( element: Element, options: StackContextOptions = {}, -): Promise => { +): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; const leadingSource = await getFiberSource(element); const stack = await getStack(element); @@ -519,15 +561,47 @@ export const getStackContext = async ( } if (leadingSource) { - return formatSourceContextLine(leadingSource, isNextProjectRuntime()); + const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); + return { + text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), + shouldAppendSelectorHint: !isTrustedSource, + }; } const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines); if (componentNames.length > 0) { - return componentNames.map((name) => `\n in ${name}`).join(""); + return { + text: componentNames.map((name) => `\n in ${name}`).join(""), + shouldAppendSelectorHint: true, + }; } - return ""; + return { + text: "", + shouldAppendSelectorHint: true, + }; +}; + +export const getStackContext = async ( + element: Element, + options: StackContextOptions = {}, +): Promise => { + const traceContext = await getTraceContext(element, options); + return traceContext.text; +}; + +const getSelectorContext = (element: Element, traceContext: TraceContextResult): string => + traceContext.shouldAppendSelectorHint ? formatSelectorContextLine(element) : ""; + +export const getElementReferenceContext = async ( + element: Element, + options: StackContextOptions = {}, +): Promise => { + const traceContext = await getTraceContext(element, options); + return `${getInlineHTMLPreview(element)}${traceContext.text}${getSelectorContext( + element, + traceContext, + )}`; }; export const getElementContext = async ( @@ -536,10 +610,11 @@ export const getElementContext = async ( ): Promise => { const resolvedElement = findNearestFiberElement(element); const html = getHTMLPreview(resolvedElement); - const stackContext = await getStackContext(resolvedElement, options); + const traceContext = await getTraceContext(resolvedElement, options); + const selectorContext = getSelectorContext(resolvedElement, traceContext); - if (stackContext) { - return `${html}${stackContext}`; + if (traceContext.text || selectorContext) { + return `${html}${traceContext.text}${selectorContext}`; } return getFallbackContext(resolvedElement); @@ -552,8 +627,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`; @@ -614,19 +689,6 @@ const formatAttrsForPreview = (element: Element): string => { return identifyingParts.join("") + remainingParts.join("") + classAttr; }; -const getDirectTextContent = (element: Element): string => { - let directText = ""; - for (const node of element.childNodes) { - if (node.nodeType === Node.TEXT_NODE) { - const trimmed = node.textContent?.trim() ?? ""; - if (trimmed) { - directText += (directText ? " " : "") + trimmed; - } - } - } - return directText; -}; - const formatChildElements = (elements: Array): string => { if (elements.length === 0) return ""; if (elements.length <= 2) { @@ -647,8 +709,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}`; @@ -659,7 +721,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 = []; const bottomElements: Array = []; @@ -682,12 +744,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}`; if (content.length > 0) { return `<${tagName}${attrsText}>${content}\n`; diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index e63542667..d2d69b82d 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,4 +1,4 @@ -import { getInlineHTMLPreview, getStackContext } from "./context.js"; +import { getElementReferenceContext } from "./context.js"; import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; @@ -15,11 +15,8 @@ interface CopyFlowHooks { onCopyError: (error: Error) => void; } -const formatElementReference = async (element: Element): Promise => { - const inlinePreview = getInlineHTMLPreview(element); - const inlineStack = (await getStackContext(element)).replace(/\n\s+/g, " "); - return `[${inlinePreview}${inlineStack}]`; -}; +const formatElementReference = async (element: Element): Promise => + `[${(await getElementReferenceContext(element)).replace(/\n\s+/g, " ")}]`; const buildClipboardPayload = async (elements: Element[]): Promise => { const references = await Promise.all(elements.map(formatElementReference)); diff --git a/packages/react-grab/src/utils/create-element-selector.ts b/packages/react-grab/src/utils/create-element-selector.ts index 0352286d8..4bc0766b5 100644 --- a/packages/react-grab/src/utils/create-element-selector.ts +++ b/packages/react-grab/src/utils/create-element-selector.ts @@ -18,6 +18,8 @@ const PREFERRED_SELECTOR_ATTRIBUTE_NAMES = new Set([ "data-cy", "data-qa", "aria-label", + "href", + "src", "role", "name", "title", diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts new file mode 100644 index 000000000..74c0c89f3 --- /dev/null +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -0,0 +1,52 @@ +const DESCENDANT_TEXT_TAGS = new Set(["a", "code", "pre"]); +const SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); + +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) continue; + + const trimmed = collapseTextContent(node.textContent ?? ""); + if (trimmed) { + directText += (directText ? " " : "") + trimmed; + } + } + return directText; +}; + +const shouldSkipElementText = (element: Element): boolean => { + if (element.getAttribute("aria-hidden") === "true") return true; + if (element.hasAttribute("hidden")) return true; + return SKIPPED_TEXT_TAGS.has(element.tagName.toLowerCase()); +}; + +const collectDescendantText = (node: Node, parts: string[]): void => { + if (node.nodeType === Node.TEXT_NODE) { + const trimmed = collapseTextContent(node.textContent ?? ""); + if (trimmed) parts.push(trimmed); + return; + } + + if (!(node instanceof Element)) return; + if (shouldSkipElementText(node)) return; + + for (const childNode of node.childNodes) { + collectDescendantText(childNode, parts); + } +}; + +export const getPreviewTextContent = (element: Element, tagName: string): string => { + if (shouldSkipElementText(element)) return ""; + + const directText = getDirectTextContent(element); + if (!DESCENDANT_TEXT_TAGS.has(tagName)) return directText; + if (directText && element.children.length === 0) return directText; + + const parts: string[] = []; + for (const childNode of element.childNodes) { + collectDescendantText(childNode, parts); + } + return collapseTextContent(parts.join(" ")); +};