diff --git a/.changeset/1784492021-9877-ccff.md b/.changeset/1784492021-9877-ccff.md new file mode 100644 index 00000000..0bbb3aef --- /dev/null +++ b/.changeset/1784492021-9877-ccff.md @@ -0,0 +1,8 @@ +--- +"@wdio/devtools-core": patch +--- + +### 🐛 Fixes +- Sanitize page source XML that may contain HTML artifacts +- Enrich android-based locators when only className is present with child-text + diff --git a/packages/core/src/element-snapshot.ts b/packages/core/src/element-snapshot.ts index e94e2c55..40cdcf02 100644 --- a/packages/core/src/element-snapshot.ts +++ b/packages/core/src/element-snapshot.ts @@ -258,6 +258,28 @@ function classifyMobileRole( return IOS_ROLE_MAP[tagName] || simplifyTag(tagName) } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Clickable container whose label lives on a child TextView. */ +function getFirstChildText(element: JSONElement): string | undefined { + // Breadth-first: direct children checked before grandchildren. + // This prefers a direct sibling label over a deeply nested one. + const queue: JSONElement[] = [...(element.children || [])] + while (queue.length > 0) { + const el = queue.shift()! + const text = el.attributes?.text?.trim() + if (text) { + return text + } + if (el.children) { + queue.push(...el.children) + } + } + return undefined +} + // --------------------------------------------------------------------------- // Locator generation // --------------------------------------------------------------------------- @@ -453,7 +475,7 @@ function collectMobileNodes( ): void { const attrs = element.attributes const role = classifyMobileRole(element.tagName, platform) - const name = getMobileNodeIdentity(attrs, platform) + let name = getMobileNodeIdentity(attrs, platform) const explicit = isExplicitlyInteractive(attrs, platform) const interactive = isMobileInteractive(element, platform) const inViewport = isMobileInViewport(element, platform, walkOpts.viewport) @@ -511,6 +533,23 @@ function collectMobileNodes( ? getBestAndroidLocator(attrs) : getBestIOSLocator(attrs)) ?? '' } + + // When the only locator is class-based and the element has no name, + // pull a label from a child — common in Android native apps where a + // clickable container row's label lives on a child TextView. We + // enrich the name rather than replacing the selector so the locator + // still targets the correct (parent) element. + if ( + !name && + platform === 'android' && + (locator.startsWith('class:') || + locator.startsWith('android=new UiSelector().className("')) + ) { + const childText = getFirstChildText(element) + if (childText && childText.length < 100) { + name = childText + } + } } nodes.push({ @@ -782,7 +821,9 @@ export function serializeMobileSnapshot( function extractTagFromSelector(selector: string, fallback: string): string { // Matches tag name followed by a CSS selector combinator or operator. // Supports hyphenated custom elements (my-component) and pseudo-classes (:nth-of-type). - const match = selector.match(/^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*)[*.#\[:=(^$~]/) + const match = selector.match( + /^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*)[*.#\[:=(^$~]/ + ) if (match) { return match[1] } @@ -794,7 +835,10 @@ function extractTagFromSelector(selector: string, fallback: string): string { } /** Walk backwards to find the nearest structural container name for ∈ context. */ -function findContextName(nodes: SnapshotNode[], index: number): string | undefined { +function findContextName( + nodes: SnapshotNode[], + index: number +): string | undefined { const myDepth = nodes[index].depth for (let i = index - 1; i >= 0; i--) { if (nodes[i].depth <= myDepth && nodes[i].name) { @@ -828,7 +872,10 @@ export function buildSnapshot( const selectorCounts = new Map() for (const node of nodes) { if (node.isInteractive && node.selector) { - selectorCounts.set(node.selector, (selectorCounts.get(node.selector) ?? 0) + 1) + selectorCounts.set( + node.selector, + (selectorCounts.get(node.selector) ?? 0) + 1 + ) } } @@ -929,9 +976,10 @@ export function accessibilityNodesToSnapshotNodes( continue } - const tagName = isInteractive && node.selector - ? extractTagFromSelector(node.selector, node.role) - : node.role + const tagName = + isInteractive && node.selector + ? extractTagFromSelector(node.selector, node.role) + : node.role result.push({ role: node.role, @@ -963,13 +1011,14 @@ export function jsonElementToSnapshotNodes( const { inViewportOnly = true } = options ?? {} const effectiveViewport = options?.viewport ?? { width: 9999, height: 9999 } const automationName = platform === 'android' ? 'uiautomator2' : 'xcuitest' + const effectiveXML = options?.sourceXML || root.attributes._sourceXML const mobileNodes: MobileFlatNode[] = [] collectMobileNodes(root, platform, 0, mobileNodes, { inViewportOnly, viewport: effectiveViewport, - sourceXML: options?.sourceXML, - automationName: options?.sourceXML ? automationName : undefined + sourceXML: effectiveXML, + automationName: effectiveXML ? automationName : undefined }) suppressTagOnlyChildren(mobileNodes) diff --git a/packages/core/src/locators/locator-generation.ts b/packages/core/src/locators/locator-generation.ts index 8586ff66..c04e9e3a 100644 --- a/packages/core/src/locators/locator-generation.ts +++ b/packages/core/src/locators/locator-generation.ts @@ -32,8 +32,13 @@ function isValidValue(value: string | undefined): value is string { /** * Escape special characters in text for use in selectors */ -function escapeText(text: string): string { - return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') +export function escapeText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') } /** diff --git a/packages/core/src/locators/xml-parsing.ts b/packages/core/src/locators/xml-parsing.ts index a100a042..e48d3d94 100644 --- a/packages/core/src/locators/xml-parsing.ts +++ b/packages/core/src/locators/xml-parsing.ts @@ -104,13 +104,66 @@ function isSameElement(node1: XMLNode, node2: XMLNode): boolean { return false } +/** + * Sanitize page source XML that may contain HTML artifacts (WebView content). + * + * Handles two classes of invalid XML that xmldom's strict parser rejects: + * 1. Bare ampersands — `&` not part of a valid XML entity (e.g. ` `, + * `©`, bare `&` in URLs) → replaced with `&`. + * 2. HTML void elements without explicit self-closure (``, ``, + * `
`, ``, ``, `
`, ``, ``, ``, + * ``, ``, ``, ``) → closed with `/>`. + */ +function sanitizePageSource(sourceXML: string): string { + // Pass 1: escape bare ampersands — & not followed by a known XML entity. + let sanitized = sourceXML.replace( + /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, + '&' + ) + + // Pass 2: self-close known HTML void elements. + const VOID_ELEMENTS = [ + 'link', + 'meta', + 'br', + 'hr', + 'img', + 'input', + 'source', + 'area', + 'base', + 'col', + 'embed', + 'track', + 'wbr' + ] + + for (const tag of VOID_ELEMENTS) { + // (not already self-closed) + sanitized = sanitized.replace( + // eslint-disable-next-line security/detect-non-literal-regexp -- tag from hardcoded list + new RegExp(`<${tag}(\\s[^>]*?[^/])>`, 'gi'), + `<${tag}$1/>` + ) + // (no attributes) + sanitized = sanitized.replace( + // eslint-disable-next-line security/detect-non-literal-regexp -- tag from hardcoded list + new RegExp(`<${tag}>`, 'gi'), + `<${tag}/>` + ) + } + + return sanitized +} + /** * Convert XML page source to JSON tree structure */ export function xmlToJSON(sourceXML: string): JSONElement | null { try { + const sanitized = sanitizePageSource(sourceXML) const parser = new DOMParser() - const sourceDoc = parser.parseFromString(sourceXML, 'text/xml') + const sourceDoc = parser.parseFromString(sanitized, 'text/xml') // xmldom 0.9+ throws ParseError for fatal errors (caught below); this catches non-fatal cases const parseErrors = sourceDoc.getElementsByTagName('parsererror') @@ -143,8 +196,9 @@ export function xmlToJSON(sourceXML: string): JSONElement | null { */ export function xmlToDOM(sourceXML: string): XMLDocument | null { try { + const sanitized = sanitizePageSource(sourceXML) const parser = new DOMParser() - const doc = parser.parseFromString(sourceXML, 'text/xml') + const doc = parser.parseFromString(sanitized, 'text/xml') // xmldom 0.9+ throws ParseError for fatal errors (caught below); this catches non-fatal cases const parseErrors = doc.getElementsByTagName('parsererror') diff --git a/packages/core/tests/xml-parsing.test.ts b/packages/core/tests/xml-parsing.test.ts new file mode 100644 index 00000000..1e7a9c95 --- /dev/null +++ b/packages/core/tests/xml-parsing.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { xmlToJSON } from '../src/locators/xml-parsing.js' + +describe('xmlToJSON', () => { + it('parses valid Android XML', () => { + const xml = ` + + + + +` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + expect(result!.tagName).toBe('hierarchy') + expect(result!.children).toHaveLength(1) + expect(result!.children[0].tagName).toBe('android.widget.FrameLayout') + expect(result!.children[0].children[0].tagName).toBe( + 'android.widget.Button' + ) + }) + + it('parses valid iOS XML', () => { + const xml = ` + + + + +` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + expect(result!.tagName).toBe('XCUIElementTypeApplication') + }) + + it('survives HTML void elements — without self-closure (issue #240)', () => { + // iOS WebView page source can contain HTML tags without /> close. + // xmldom rejects these as "Opening and ending tag mismatch: link != head". + const xml = ` + + + + + + + + Test Page + + + + +` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + }) + + it('survives bare ampersands in attribute values', () => { + // URLs with query params contain bare & that xmldom rejects. + const xml = ` + + + + +` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + }) + + it('survives HTML void element without self-closure', () => { + const xml = ` + + +
+ Photo +
+
+
+
` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + }) + + it('returns null for genuinely broken XML', () => { + const xml = 'text' + const result = xmlToJSON(xml) + expect(result).toBeNull() + }) + + it('keeps properly self-closed void elements unchanged', () => { + const xml = ` + + +
+ +
+ +
+
` + const result = xmlToJSON(xml) + expect(result).not.toBeNull() + // should have 4 element children + const container = result!.children[0] + expect(container!.children).toHaveLength(4) + }) +})