diff --git a/lib/helper/CDPBrowser.js b/lib/helper/CDPBrowser.js index 105c4b420..a67980e30 100644 --- a/lib/helper/CDPBrowser.js +++ b/lib/helper/CDPBrowser.js @@ -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 { @@ -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) } /** diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js index 6cfcf420a..319df049f 100644 --- a/lib/helper/Playwright.js +++ b/lib/helper/Playwright.js @@ -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() } diff --git a/lib/helper/clientscripts/cdpBrowserClient.js b/lib/helper/clientscripts/cdpBrowserClient.js index d72fcff68..5e261c09b 100644 --- a/lib/helper/clientscripts/cdpBrowserClient.js +++ b/lib/helper/clientscripts/cdpBrowserClient.js @@ -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 => { @@ -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) => { @@ -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') diff --git a/lib/listener/globalTimeout.js b/lib/listener/globalTimeout.js index 7acd9c78e..7f89c73c0 100644 --- a/lib/listener/globalTimeout.js +++ b/lib/listener/globalTimeout.js @@ -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 } }) diff --git a/lib/plugin/aiTrace.js b/lib/plugin/aiTrace.js index 21c2891ad..4f6a871d3 100644 --- a/lib/plugin/aiTrace.js +++ b/lib/plugin/aiTrace.js @@ -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 diff --git a/lib/step/helper.js b/lib/step/helper.js index a87148ac2..f23d822b7 100644 --- a/lib/step/helper.js +++ b/lib/step/helper.js @@ -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') diff --git a/package.json b/package.json index 4b40a3cc6..8b8f48518 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/test/data/app/view/form/inline_wrapper.php b/test/data/app/view/form/inline_wrapper.php new file mode 100644 index 000000000..8ba736d32 --- /dev/null +++ b/test/data/app/view/form/inline_wrapper.php @@ -0,0 +1,11 @@ + + + +
card
+
+ + +
hidden
+
+ + diff --git a/test/data/app/view/form/mousedown_trigger.php b/test/data/app/view/form/mousedown_trigger.php new file mode 100644 index 000000000..2568927c7 --- /dev/null +++ b/test/data/app/view/form/mousedown_trigger.php @@ -0,0 +1,20 @@ + + +
open
+ + + + + diff --git a/test/data/app/view/form/wrapped_button.php b/test/data/app/view/form/wrapped_button.php new file mode 100644 index 000000000..b34ad14ce --- /dev/null +++ b/test/data/app/view/form/wrapped_button.php @@ -0,0 +1,15 @@ + + + + + + + diff --git a/test/helper/CDPBrowser_chrome_test.js b/test/helper/CDPBrowser_chrome_test.js index 99d373cb1..30449025e 100644 --- a/test/helper/CDPBrowser_chrome_test.js +++ b/test/helper/CDPBrowser_chrome_test.js @@ -53,6 +53,20 @@ describe('CDPBrowser (against Chrome)', function () { expect(await I.grabSource()).to.include(' { + 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) diff --git a/test/helper/CDPBrowser_obscura_test.js b/test/helper/CDPBrowser_obscura_test.js index bbba0c960..6d2c44b75 100644 --- a/test/helper/CDPBrowser_obscura_test.js +++ b/test/helper/CDPBrowser_obscura_test.js @@ -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 diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js index 45c522edf..784c2d954 100644 --- a/test/helper/Playwright_test.js +++ b/test/helper/Playwright_test.js @@ -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') diff --git a/test/unit/listener/globalTimeout_hooks_test.js b/test/unit/listener/globalTimeout_hooks_test.js new file mode 100644 index 000000000..dd0bc5207 --- /dev/null +++ b/test/unit/listener/globalTimeout_hooks_test.js @@ -0,0 +1,39 @@ +import { expect } from 'chai' +import event from '../../../lib/event.js' +import store from '../../../lib/store.js' +import globalTimeoutListener from '../../../lib/listener/globalTimeout.js' +import { AfterSuiteHook } from '../../../lib/mocha/hooks.js' + +describe('globalTimeout listener - suite hooks', () => { + let prevTimeouts + + const removeHookListeners = () => { + for (const name of Object.values(event.hook)) event.dispatcher.removeAllListeners(name) + } + + beforeEach(() => { + prevTimeouts = store.timeouts + store.timeouts = true + event.cleanDispatcher() + removeHookListeners() + globalTimeoutListener() + }) + + afterEach(() => { + store.timeouts = prevTimeouts + event.cleanDispatcher() + removeHookListeners() + }) + + it('ignores hook.finished for a suite hook that never fired hook.started', () => { + const hook = new AfterSuiteHook({ suite: { title: 'suite' } }) + expect(() => event.dispatcher.emit(event.hook.finished, hook)).not.to.throw() + }) + + it('restores saved timeouts only once per started suite hook', () => { + const hook = new AfterSuiteHook({ suite: { title: 'suite' } }) + event.dispatcher.emit(event.hook.started, hook) + event.dispatcher.emit(event.hook.finished, hook) + expect(() => event.dispatcher.emit(event.hook.finished, hook)).not.to.throw() + }) +}) diff --git a/test/unit/plugin/aiTrace_test.js b/test/unit/plugin/aiTrace_test.js index 0cff2a7f6..d1bf63666 100644 --- a/test/unit/plugin/aiTrace_test.js +++ b/test/unit/plugin/aiTrace_test.js @@ -130,6 +130,38 @@ describe('aiTrace plugin', () => { expect(test.artifacts.aiTrace).to.be.ok }) + it('should keep the trace of the running test when a Before hook fails the whole suite', async () => { + aiTrace({ + enabled: true, + output: testsDir, + }) + + const runningTest = createTest('running test') + const skippedTest = createTest('never started test') + runningTest.art = { message: 'hook failed', stack: 'Error' } + skippedTest.art = { message: 'hook failed', stack: 'Error' } + + event.dispatcher.emit(event.test.before, runningTest) + await recorder.promise() + + event.dispatcher.emit(event.step.after, { + title: 'click', + toString: () => 'I click button', + status: 'failed', + startTime: Date.now(), + endTime: Date.now() + 100, + }) + await recorder.promise() + + event.dispatcher.emit(event.test.failed, runningTest, new Error('hook failed'), 'Before') + event.dispatcher.emit(event.test.failed, skippedTest, new Error('hook failed'), 'Before') + await recorder.promise() + + expect(runningTest.artifacts.aiTrace).to.be.ok + expect(skippedTest.artifacts?.aiTrace).to.be.undefined + expect(fs.readFileSync(runningTest.artifacts.aiTrace, 'utf8')).to.include('name: running test') + }) + it('should ignore steps matching ignoreSteps pattern', async () => { aiTrace({ enabled: true, diff --git a/test/unit/step/helper_step_duration_test.js b/test/unit/step/helper_step_duration_test.js new file mode 100644 index 000000000..0d2c6d020 --- /dev/null +++ b/test/unit/step/helper_step_duration_test.js @@ -0,0 +1,34 @@ +import { expect } from 'chai' +import HelperStep from '../../../lib/step/helper.js' + +describe('HelperStep duration', () => { + const helper = { + slowAction: () => new Promise(resolve => setTimeout(() => resolve('done'), 60)), + slowFailure: () => new Promise((_, reject) => setTimeout(() => reject(new Error('boom')), 60)), + syncAction: () => 'sync', + } + + it('ends an async step when its promise resolves', async () => { + const step = new HelperStep(helper, 'slowAction') + const result = await step.run() + expect(result).to.equal('done') + expect(step.duration).to.be.at.least(50) + }) + + it('ends an async step when its promise rejects', async () => { + const step = new HelperStep(helper, 'slowFailure') + try { + await step.run() + throw new Error('should have thrown') + } catch (err) { + expect(err.message).to.equal('boom') + } + expect(step.duration).to.be.at.least(50) + }) + + it('keeps the returned value of a sync step', () => { + const step = new HelperStep(helper, 'syncAction') + expect(step.run()).to.equal('sync') + expect(step.endTime).to.be.at.least(step.startTime) + }) +})