Skip to content
Open
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
67 changes: 45 additions & 22 deletions lib/helper/CDPBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -1944,16 +1944,25 @@ class CDPBrowser extends Helper {
this._trafficListenersInstalled = true
this.cdp.on('Network.requestWillBeSent', (params, sessionId) => this._onTrafficRequest(params, sessionId))
this.cdp.on('Network.responseReceived', (params, sessionId) => this._onTrafficResponse(params, sessionId))
this.cdp.on('Network.loadingFinished', (params, sessionId) => this._onTrafficLoadingFinished(params, sessionId))
this.cdp.on('Network.loadingFailed', (params, sessionId) => this._onTrafficLoadingFailed(params, sessionId))
}

_onTrafficRequest(params, sessionId) {
if (!this.recording || sessionId !== this.sessionId) return
let resolveResponse
if (sessionId !== this.sessionId) return
if (params.redirectResponse) {
const redirected = this._pendingTrafficResponses.get(params.requestId)
if (redirected) {
this._pendingTrafficResponses.delete(params.requestId)
redirected.resolve(this._trafficResponse(params.redirectResponse, null))
if (this.recording) this.requests.push(redirected.request)
}
}
if (!this.recording) return
let resolve
const response = new Promise(r => {
resolveResponse = r
resolve = r
})
this._pendingTrafficResponses.set(params.requestId, resolveResponse)
let requestPostData = params.request.postData
if (requestPostData) {
try {
Expand All @@ -1962,42 +1971,56 @@ class CDPBrowser extends Helper {
// not JSON, keep as string
}
}
this.requests.push({
url: params.request.url,
method: params.request.method,
requestHeaders: params.request.headers,
requestPostData,
response,
this._pendingTrafficResponses.set(params.requestId, {
resolve,
request: {
url: params.request.url,
method: params.request.method,
requestHeaders: params.request.headers,
requestPostData,
response,
},
})
}

_onTrafficResponse(params, sessionId) {
if (sessionId !== this.sessionId) return
const resolve = this._pendingTrafficResponses.get(params.requestId)
if (!resolve) return
this._pendingTrafficResponses.delete(params.requestId)
const { response } = params
resolve({
_trafficResponse(response, requestId) {
return {
url: () => response.url,
status: () => response.status,
statusText: () => response.statusText,
body: async () => {
if (!requestId) return Buffer.from('')
try {
const res = await this.cdp.send('Network.getResponseBody', { requestId: params.requestId }, this.sessionId)
const res = await this.cdp.send('Network.getResponseBody', { requestId }, this.sessionId)
return res.base64Encoded ? Buffer.from(res.body, 'base64') : Buffer.from(res.body)
} catch (e) {
return Buffer.from('')
}
},
})
}
}

_onTrafficResponse(params, sessionId) {
if (sessionId !== this.sessionId) return
const pending = this._pendingTrafficResponses.get(params.requestId)
if (!pending) return
pending.resolve(this._trafficResponse(params.response, params.requestId))
}

_onTrafficLoadingFinished(params, sessionId) {
if (sessionId !== this.sessionId) return
const pending = this._pendingTrafficResponses.get(params.requestId)
if (!pending) return
this._pendingTrafficResponses.delete(params.requestId)
if (this.recording) this.requests.push(pending.request)
}

_onTrafficLoadingFailed(params, sessionId) {
if (sessionId !== this.sessionId) return
const resolve = this._pendingTrafficResponses.get(params.requestId)
if (!resolve) return
const pending = this._pendingTrafficResponses.get(params.requestId)
if (!pending) return
this._pendingTrafficResponses.delete(params.requestId)
resolve(null)
pending.resolve(null)
}

/**
Expand Down
6 changes: 6 additions & 0 deletions lib/helper/Playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -4257,6 +4257,12 @@ export function buildLocatorString(locator) {
// { shadow: ['my-app', 'my-form', 'button'] } => 'my-app >> my-form >> button'
return locator.value.join(' >> ')
}
if (locator.isRole()) {
const { text, name, exact } = locator.locator || {}
const accessibleName = name ?? text
if (accessibleName === undefined) return `internal:role=${locator.value}`
return `internal:role=${locator.value}[name=${JSON.stringify(String(accessibleName))}${exact === true ? 's' : 'i'}]`
}
return locator.simplify()
}

Expand Down
42 changes: 35 additions & 7 deletions lib/helper/clientscripts/cdpBrowserClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
}
return []
}
const isElementVisible = el => {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden') return false
const r = el.getBoundingClientRect()
if (r.width > 0 && r.height > 0) return true
if (style.display !== 'inline' && style.display !== 'contents') return false
return Array.from(el.children).some(isElementVisible)
}
const INTERACTIVE = 'a[href], button, input, select, textarea, label, summary, option, [role="button"], [role="link"], [role="menuitem"], [role="option"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"]'
const clickTarget = (el, x, y) => {
if (el.matches(INTERACTIVE) || typeof document.elementFromPoint !== 'function') return el
let hit = document.elementFromPoint(x, y)
if (!hit || hit === el || !el.contains(hit)) return el
const control = hit.closest(INTERACTIVE)
if (control && el.contains(control)) return control
while (hit && !(hit instanceof HTMLElement)) hit = hit.parentElement
return hit && el.contains(hit) ? hit : el
}
const SKIP_TEXT_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1 }
const BLOCK_TEXT_TAGS = { DIV: 1, P: 1, BR: 1, LI: 1, TR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SECTION: 1, ARTICLE: 1, UL: 1, OL: 1, TABLE: 1, FORM: 1 }
const visibleText = root => {
Expand Down Expand Up @@ -327,8 +345,22 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
},
click: els => {
const el = els[0]
el.click()
if (el.tagName === 'LABEL') focusLabelledControl(el)
const r = el.getBoundingClientRect()
const x = r.x + r.width / 2
const y = r.y + r.height / 2
const target = clickTarget(el, x, y)
if (!target.disabled) {
const init = { bubbles: true, cancelable: true, composed: true, view: window, button: 0, clientX: x, clientY: y }
const fire = (Ctor, type, buttons) => target.dispatchEvent(new Ctor(type, { ...init, buttons, pointerId: 1, pointerType: 'mouse', isPrimary: true }))
const Pointer = typeof PointerEvent === 'function' ? PointerEvent : null
if (Pointer) fire(Pointer, 'pointerdown', 1)
const focusAllowed = fire(MouseEvent, 'mousedown', 1)
if (focusAllowed && typeof target.focus === 'function') target.focus()
if (Pointer) fire(Pointer, 'pointerup', 0)
fire(MouseEvent, 'mouseup', 0)
}
target.click()
if (target.tagName === 'LABEL') focusLabelledControl(target)
return true
},
fill: (els, p) => {
Expand Down Expand Up @@ -451,11 +483,7 @@ export default function installCodeceptClient(xpathNeedsPolyfill) {
dispatch('drop')
return true
},
visibleCount: els => els.filter(el => {
const r = el.getBoundingClientRect()
const style = getComputedStyle(el)
return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
}).length,
visibleCount: els => els.filter(isElementVisible).length,
}
const candidatesNeedXPath = (candidates, within) => {
const hasXPath = arr => Array.isArray(arr) && arr.some(c => c && c.type === 'xpath')
Expand Down
3 changes: 3 additions & 0 deletions lib/listener/globalTimeout.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ export default function () {

event.dispatcher.on(event.hook.finished, hook => {
if (hook instanceof BeforeSuiteHook || hook instanceof AfterSuiteHook) {
if (__prevSuiteTimeout === undefined) return
// restore previously stored values
timeout = __prevTimeout
suiteTimeout = __prevSuiteTimeout.slice()
__prevTimeout = undefined
__prevSuiteTimeout = undefined
}
})

Expand Down
1 change: 1 addition & 0 deletions lib/plugin/aiTrace.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export default function (config = {}) {
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
return
}
if (test !== currentTest) return
recorder.add('aiTrace:persist failed', async () => {
if (pendingArtifactCapture) {
await pendingArtifactCapture
Expand Down
6 changes: 6 additions & 0 deletions lib/step/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class HelperStep extends Step {
}
this.setStatus('success')
this.endTime = +Date.now()
if (result && typeof result.then === 'function') {
const finish = () => {
this.endTime = +Date.now()
}
result.then(finish, finish)
}
} catch (err) {
this.endTime = +Date.now()
this.setStatus('failed')
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"arrify": "3.0.0",
"axios": "^1.20.0",
"chalk": "4.1.2",
"cheerio": "^1.0.0",
"chokidar": "^5.0.0",
"commander": "15.0.0",
"cross-spawn": "7.0.6",
Expand Down Expand Up @@ -163,6 +162,7 @@
"chai": "^6.2.1",
"chai-as-promised": "^8.0.2",
"chai-subset": "1.6.0",
"cheerio": "^1.0.0",
"documentation": "14.0.3",
"electron": "40.2.1",
"eslint": "^9.36.0",
Expand Down
11 changes: 11 additions & 0 deletions test/data/app/view/form/inline_wrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<html>
<body>
<a id="card-link" href="#">
<div style="height:20px">card</div>
</a>
<a id="empty-link" href="#"></a>
<span id="hidden-card" style="display:contents">
<div style="display:none">hidden</div>
</span>
</body>
</html>
20 changes: 20 additions & 0 deletions test/data/app/view/form/mousedown_trigger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<html>
<body>
<div id="mousedown-trigger" tabindex="0" style="height:20px">open</div>
<div id="mousedown-menu" style="display:none">menu</div>
<button id="disabled-btn" disabled>off</button>
<script>
window.__clickEvents = []
var trigger = document.getElementById('mousedown-trigger')
;['pointerdown', 'mousedown', 'focus', 'pointerup', 'mouseup', 'click'].forEach(function (type) {
trigger.addEventListener(type, function () { window.__clickEvents.push(type) })
})
trigger.addEventListener('mousedown', function () {
document.getElementById('mousedown-menu').style.display = 'block'
})
document.getElementById('disabled-btn').addEventListener('mousedown', function () {
window.__clickEvents.push('disabled-mousedown')
})
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions test/data/app/view/form/wrapped_button.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<html>
<body>
<ul>
<li id="menu-item">
<button type="button" id="menu-button" style="display:block;width:100%;height:30px"><span>New suite</span></button>
</li>
</ul>
<div id="opened" style="display:none">opened</div>
<script>
document.getElementById('menu-button').addEventListener('click', function () {
document.getElementById('opened').style.display = 'block'
})
</script>
</body>
</html>
14 changes: 14 additions & 0 deletions test/helper/CDPBrowser_chrome_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ describe('CDPBrowser (against Chrome)', function () {
expect(await I.grabSource()).to.include('<html')
})

it('records each redirect hop once its response arrived', async () => {
await I.amOnPage('/')
await I.startRecordingTraffic()
await I.amOnPage('/redirect')
await I.seeTraffic({ name: 'redirected page', url: '/info', timeout: 5 })
const traffic = await I.grabRecordedNetworkTraffics()
const redirect = traffic.find(t => t.url.endsWith('/redirect'))
const info = traffic.find(t => t.url.endsWith('/info'))
expect(redirect && redirect.response.status).to.equal(302)
expect(info && info.response.status).to.equal(200)
I.stopRecordingTraffic()
I.recordedAtLeastOnce = false
})

it('executes scripts with arguments', async () => {
await I.amOnPage('/')
const val = await I.executeScript((a, b) => a + b, 2, 3)
Expand Down
37 changes: 37 additions & 0 deletions test/helper/CDPBrowser_obscura_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ describe('Obscura helper (self-managed obscura serve)', function () {
}
})

it('sees an inline link that wraps only block children', async function () {
await I.amOnPage('/form/inline_wrapper')
if (I.capabilities.layout !== 'real') this.skip()
await I.seeElement('#card-link')
await I.waitForVisible('#card-link', 1)
await I.dontSeeElement('#empty-link')
await I.dontSeeElement('#hidden-card')
})

it('clicks with the pointer and mouse events a real click fires', async () => {
await I.amOnPage('/form/mousedown_trigger')
await I.click('#mousedown-trigger')
await I.forceClick('#disabled-btn')
const events = await I.executeScript(() => window.__clickEvents)
expect(events.filter(e => e !== 'pointerdown' && e !== 'pointerup')).to.deep.equal(['mousedown', 'focus', 'mouseup', 'click'])
await I.seeElement('#mousedown-menu')
})

it('clicks the control a wrapper element covers, like a real click lands', async function () {
await I.amOnPage('/form/wrapped_button')
if (I.capabilities.layout !== 'real') this.skip()
await I.click('#menu-item')
await I.seeElement('#opened')
})

it('records traffic after the response finished loading', async () => {
await I.amOnPage('/')
await I.startRecordingTraffic()
await I.amOnPage('/info')
await I.seeTraffic({ name: 'info page', url: '/info', timeout: 5 })
const traffic = await I.grabRecordedNetworkTraffics()
const info = traffic.find(t => t.url.endsWith('/info'))
expect(info && info.response.status).to.equal(200)
I.stopRecordingTraffic()
I.recordedAtLeastOnce = false
})

it('skips screenshots without failing when there is no rendering engine', async function () {
await I.amOnPage('/')
if (I.capabilities.screenshot !== false) this.skip() // this binary can render; saveScreenshot writes a file instead
Expand Down
19 changes: 19 additions & 0 deletions test/helper/Playwright_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,25 @@ describe('Playwright', function () {
})
})

describe('#waitForVisible #waitForInvisible - role locators', () => {
it('should wait for a native button by its role and accessible name', async () => {
await I.amOnPage('/form/example1')
await I.waitForVisible({ role: 'button', text: 'Login', exact: true }, 2)
await I.waitForElement({ role: 'button', text: 'Log' }, 2)
await I.waitForInvisible({ role: 'button', text: 'Logout' }, 2)
})

it('should not match a partial name when exact is set', async () => {
await I.amOnPage('/form/example1')
try {
await I.waitForVisible({ role: 'button', text: 'Log', exact: true }, 1)
throw new Error('should have thrown')
} catch (err) {
expect(err.message).to.include('still not visible')
}
})
})

describe('#waitForVisible #waitForInvisible - within block', () => {
it('should wait for visible element', async () => {
await I.amOnPage('/iframe')
Expand Down
Loading
Loading