Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .changeset/1784492021-9877-ccff.md
Original file line number Diff line number Diff line change
@@ -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

67 changes: 58 additions & 9 deletions packages/core/src/element-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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]
}
Expand All @@ -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) {
Expand Down Expand Up @@ -828,7 +872,10 @@ export function buildSnapshot(
const selectorCounts = new Map<string, number>()
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
)
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/locators/locator-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}

/**
Expand Down
58 changes: 56 additions & 2 deletions packages/core/src/locators/xml-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. `&nbsp;`,
* `&copy;`, bare `&` in URLs) → replaced with `&amp;`.
* 2. HTML void elements without explicit self-closure (`<link>`, `<meta>`,
* `<br>`, `<img>`, `<input>`, `<hr>`, `<source>`, `<area>`, `<base>`,
* `<col>`, `<embed>`, `<track>`, `<wbr>`) → 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,
'&amp;'
)

// 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) {
// <tagname ...> → <tagname .../> (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/>`
)
// <tagname> → <tagname/> (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')
Expand Down Expand Up @@ -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')
Expand Down
105 changes: 105 additions & 0 deletions packages/core/tests/xml-parsing.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<android.widget.FrameLayout bounds="[0,0][1080,1920]"
class="android.widget.FrameLayout">
<android.widget.Button bounds="[100,200][300,400]" text="Submit"
class="android.widget.Button" clickable="true"/>
</android.widget.FrameLayout>
</hierarchy>`
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 = `<?xml version="1.0" encoding="UTF-8"?>
<XCUIElementTypeApplication>
<XCUIElementTypeWindow>
<XCUIElementTypeButton name="Login" label="Login" enabled="true"/>
</XCUIElementTypeWindow>
</XCUIElementTypeApplication>`
const result = xmlToJSON(xml)
expect(result).not.toBeNull()
expect(result!.tagName).toBe('XCUIElementTypeApplication')
})

it('survives HTML void elements — <link> without self-closure (issue #240)', () => {
// iOS WebView page source can contain HTML <link> tags without /> close.
// xmldom rejects these as "Opening and ending tag mismatch: link != head".
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<XCUIElementTypeApplication>
<XCUIElementTypeWindow>
<XCUIElementTypeWebView>
<XCUIElementTypeOther>
<head>
<link rel="stylesheet" href="style.css">
<meta charset="utf-8">
<title>Test Page</title>
</head>
</XCUIElementTypeOther>
</XCUIElementTypeWebView>
</XCUIElementTypeWindow>
</XCUIElementTypeApplication>`
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 = `<?xml version="1.0" encoding="UTF-8"?>
<XCUIElementTypeApplication>
<XCUIElementTypeLink name="page?x=1&amp;y=2" label="A & B">
<XCUIElementTypeStaticText value="foo & bar"/>
</XCUIElementTypeLink>
</XCUIElementTypeApplication>`
const result = xmlToJSON(xml)
expect(result).not.toBeNull()
})

it('survives HTML void element <img> without self-closure', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<XCUIElementTypeApplication>
<XCUIElementTypeWebView>
<div>
<img src="photo.png" alt="Photo">
<br>
</div>
</XCUIElementTypeWebView>
</XCUIElementTypeApplication>`
const result = xmlToJSON(xml)
expect(result).not.toBeNull()
})

it('returns null for genuinely broken XML', () => {
const xml = '<open><unclosed>text</open>'
const result = xmlToJSON(xml)
expect(result).toBeNull()
})

it('keeps properly self-closed void elements unchanged', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<root>
<container>
<br/>
<img src="a.png"/>
<hr/>
<input type="text" value="hi"/>
</container>
</root>`
const result = xmlToJSON(xml)
expect(result).not.toBeNull()
// <container> should have 4 element children
const container = result!.children[0]
expect(container!.children).toHaveLength(4)
})
})
Loading