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
13 changes: 12 additions & 1 deletion lib/locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,13 +591,24 @@ Locator.clickable = {
`.//*[@title = ${literal}]`,
`.//*[@aria-labelledby = //*[@id][normalize-space(string(.)) = ${literal}]/@id ]`,
`.//*[@role='button'][normalize-space(.)=${literal}]`,
`.//*[@role='tab' or @role='link' or @role='menuitem' or @role='menuitemcheckbox' or @role='menuitemradio' or @role='option' or @role='treeitem'][contains(normalize-space(string(.)), ${literal})]`,
]),

/**
* @param {string} literal
* @returns {string}
*/
self: literal => `./self::*[contains(normalize-space(string(.)), ${literal}) or contains(normalize-space(@value), ${literal})]`,
self: literal => {
// Narrowest-match: prefer the deepest descendant whose string-value contains the literal.
// Falling back to `self` without the `not(descendant...)` guard would match a container
// whose concatenated text happens to include the literal (e.g. a <ul role="tablist"> whose
// tab labels all sit in its string-value) and click the container itself.
const narrowest = `contains(normalize-space(string(.)), ${literal}) and not(.//*[contains(normalize-space(string(.)), ${literal})])`
return xpathLocator.combine([
`.//*[${narrowest}]`,
`./self::*[${narrowest} or contains(normalize-space(@value), ${literal})]`,
])
},
}

Locator.field = {
Expand Down
33 changes: 33 additions & 0 deletions test/data/app/view/form/tablist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<title>Tablist click regression (#5530)</title>
<style>
ul[role="tablist"] { display: flex; list-style: none; padding: 0; margin: 0; border-bottom: 1px solid #ccc; }
ul[role="tablist"] li[role="tab"] { padding: 8px 16px; cursor: pointer; }
ul[role="tablist"] li[aria-selected="true"] { background: #eef; font-weight: bold; }
</style>
</head>
<body>
<h1>Tablist</h1>
<ul role="tablist" id="tabs" class="tab-list">
<li role="tab" data-tab="description" aria-selected="true"><span class="tab-text">Description</span></li>
<li role="tab" data-tab="code" aria-selected="false"><span class="tab-text">Code template</span></li>
<li role="tab" data-tab="attachments" aria-selected="false"><span class="tab-text">Attachments</span></li>
<li role="tab" data-tab="runs" aria-selected="false"><span class="tab-text">Runs</span></li>
<li role="tab" data-tab="history" aria-selected="false"><span class="tab-text">History</span></li>
</ul>
<div id="selected-tab">description</div>
<script>
document.querySelectorAll('[role="tab"]').forEach(function (li) {
li.addEventListener('click', function () {
document.querySelectorAll('[role="tab"]').forEach(function (x) {
x.setAttribute('aria-selected', 'false');
});
li.setAttribute('aria-selected', 'true');
document.getElementById('selected-tab').textContent = li.dataset.tab;
});
});
</script>
</body>
</html>
30 changes: 30 additions & 0 deletions test/helper/Playwright_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1192,6 +1192,36 @@ describe('Playwright', function () {
I.see('Information')
})
})

describe('#click - with tag selector as context', () => {
it('clicks <a><span>text</span></a> when context is the tag "a"', async () => {
await I.amOnPage('/form/example7')
await I.click('Buy Chocolate Bar', 'a')
await I.seeCurrentUrlEquals('/')
})
})

describe('#click - tablist regression', () => {
// https://github.com/codeceptjs/CodeceptJS — click(text, container) was matching
// the container itself when its concatenated string-value contained the text,
// clicking the <ul role="tablist"> center instead of the intended <li role="tab">.
it('clicks the correct tab by text when container has many text-bearing children', async () => {
await I.amOnPage('/form/tablist')
await I.seeTextEquals('description', '#selected-tab')

await I.click('History', 'ul[role="tablist"]')
await I.seeTextEquals('history', '#selected-tab')

await I.click('Description', 'ul[role="tablist"]')
await I.seeTextEquals('description', '#selected-tab')

await I.click('Runs', 'ul[role="tablist"]')
await I.seeTextEquals('runs', '#selected-tab')

await I.click('Code template', 'ul[role="tablist"]')
await I.seeTextEquals('code', '#selected-tab')
})
})
})

let remoteBrowser
Expand Down
81 changes: 81 additions & 0 deletions test/unit/locator_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -714,4 +714,85 @@ describe('Locator', () => {
const nodes = xpath.select(l.toXPath(), doc)
expect(nodes).to.have.length(1, l.toXPath())
})

describe('Locator.clickable.self', () => {
it('picks deepest descendant, not a container whose string-value merely concatenates the literal (tablist regression)', () => {
const tabsXml = `<root>
<ul role="tablist" id="tabs">
<li role="tab" data-tab="description"><span class="tab-text">Description</span></li>
<li role="tab" data-tab="code"><span class="tab-text">Code template</span></li>
<li role="tab" data-tab="attachments"><span class="tab-text">Attachments</span></li>
<li role="tab" data-tab="runs"><span class="tab-text">Runs</span></li>
<li role="tab" data-tab="history"><span class="tab-text">History</span></li>
</ul>
</root>`
const tabsDoc = new DOMParser().parseFromString(tabsXml, 'text/xml')
const ul = xpath.select1('//ul', tabsDoc)
const xp = Locator.clickable.self("'Description'")
const nodes = xpath.select(xp, ul)

expect(nodes).to.have.length(1, xp)
expect(nodes[0].tagName).to.eql('span')
expect(nodes[0].textContent.trim()).to.eql('Description')
})

it('matches self when context element has the literal in direct text and no descendants contain it', () => {
const leafXml = '<root><div id="btn">Submit</div></root>'
const leafDoc = new DOMParser().parseFromString(leafXml, 'text/xml')
const div = xpath.select1('//div', leafDoc)
const xp = Locator.clickable.self("'Submit'")
const nodes = xpath.select(xp, div)

expect(nodes).to.have.length(1, xp)
expect(nodes[0].getAttribute('id')).to.eql('btn')
})

it('matches self when @value attribute contains the literal', () => {
const inputXml = '<root><input type="submit" value="Submit" id="inp"/></root>'
const inputDoc = new DOMParser().parseFromString(inputXml, 'text/xml')
const input = xpath.select1('//input', inputDoc)
const xp = Locator.clickable.self("'Submit'")
const nodes = xpath.select(xp, input)

expect(nodes).to.have.length(1, xp)
expect(nodes[0].getAttribute('id')).to.eql('inp')
})
})

describe('Locator.clickable.wide', () => {
it('matches an ARIA widget role (tab) by text within a container', () => {
const tabsXml = `<root>
<ul role="tablist" id="tabs">
<li role="tab" data-tab="description"><span class="tab-text">Description</span></li>
<li role="tab" data-tab="code"><span class="tab-text">Code template</span></li>
<li role="tab" data-tab="attachments"><span class="tab-text">Attachments</span></li>
<li role="tab" data-tab="runs"><span class="tab-text">Runs</span></li>
<li role="tab" data-tab="history"><span class="tab-text">History</span></li>
</ul>
</root>`
const tabsDoc = new DOMParser().parseFromString(tabsXml, 'text/xml')
const ul = xpath.select1('//ul', tabsDoc)
const xp = Locator.clickable.wide("'Description'")
const nodes = xpath.select(xp, ul)

const tabMatches = nodes.filter(n => n.getAttribute && n.getAttribute('role') === 'tab')
expect(tabMatches).to.have.length(1, xp)
expect(tabMatches[0].getAttribute('data-tab')).to.eql('description')
})

it('matches role="menuitem" by text', () => {
const menuXml = `<root>
<ul role="menu">
<li role="menuitem" id="save">Save</li>
<li role="menuitem" id="rename">Rename</li>
</ul>
</root>`
const menuDoc = new DOMParser().parseFromString(menuXml, 'text/xml')
const menu = xpath.select1('//ul', menuDoc)
const nodes = xpath.select(Locator.clickable.wide("'Rename'"), menu)
const items = nodes.filter(n => n.getAttribute && n.getAttribute('role') === 'menuitem')
expect(items).to.have.length(1)
expect(items[0].getAttribute('id')).to.eql('rename')
})
})
})